简述一下JVM
虚拟机概念:
虚拟机是模拟计算机执行虚拟计算机指令的软件,分为系统虚拟机[Virtual Box
、VMware
提供操作系统,对物理计算机仿真]和程序虚拟机[JVM
执行Java
字节码指令]
JVM
的工作原理是装载二进制字节码,把二进制字节码解释编译为运行平台的机器指令交给底层硬件执行
二进制码只是一个跨平台的通用契约,内部包含的仅仅是一些能被JVM
识别的字节码指令、符号表以及其他辅助信息;操作系统只能识别机器指令或者汇编指令;需要将字节码指令翻译成机器指令操作系统才能识别执行
虚拟机是相对于物理机的概念,虚拟机和物理机都有代码执行能力,区别是物理机的执行引擎直接建立在处理器、缓存、指令集和操作系统层面上,虚拟机的执行引擎是由软件自行实现的,虚拟机可以不受物理条件的制约限制指令集与执行引擎的结构体系;物理机的指令集都是和物理硬件深度绑定,比如X86
架构和ARM
架构的指令集千差万别;虚拟机能在不同硬件平台上执行同一套指令集;但是执行效率相较于物理机略差一些
JVM
特点:
一次编译,处处运行;自动内存管理;自动垃圾回收机制
Java
虚拟机是对JVM
规范的实现,oracle
发布JVM
规范,提供HotSpot
作为openJDK
和oracleJDK
的默认虚拟机,不同厂商针对JVM
规范有各自的虚拟机实现
JVM
是一种跨语言平台,JVM
的执行基础是字节码文件,JDK7
以后JVM
通过JSR292
规范实现字节码文件可以由任意语言通过编译器编译获得,只要编译后的字节码文件遵循JVM
规范就可以被JVM
识别、装载和运行;该特点让JVM
具备在大型平台上实现多语言混合编程解决特定领域问题的能力
使用基于栈的指令集架构
主流虚拟机都采用解释器和JIT
即时编译器混合工作模式
JVM
的生命周期
启动
JVM
的启动通过类加载开始
执行
Java程序实际上就是JVM
进程,JVM
将编译后的字节码解释为机器指令通过操作系统交给计算机硬件执行,使用jps
命令能查看当前计算机上执行的所有Java
进程
退出
程序正常执行结束、程序执行过程中遇到异常或者错误异常终止、操作系统错误会导致虚拟机的退出
进程中的某个线程调用Runtime
类或者System
类的exit
方法或者Runtime
的halt
方法,且Java
安全管理器允许的情况下,JVM
进程也会终止
调用JNI
的API
加载或者卸载JVM
时,JVM
进程也会终止
常见JVM
Classic VM
:1996年由Sun公司随着JDK1.0
发布的世界上第一款商用JVM
执行引擎中没有即时编译器,效率低下
可以外挂JIT
即时编译器,但是外挂JIT
即时编译器无法使用解释器,虽然执行快,但是JVM
启动时会编译大量代码造成JVM
启动时长时间卡顿
不能识别内存中的数据类型,通过句柄额外记录对象的内存地址来查找对象,不知道数据类型会带来一些麻烦,比如标记整理算法让JVM
中的内存更紧凑会移动对象的位置,如果不知道内存存放的是数据本身还是引用会比较麻烦
Exact VM
:JDK1.2
由SUN公司发布
提供准确式内存管理,虚拟机可以知道指定内存中的数据类型
实现了解释器和即时编译器的混合工作
还没投产就被HotSpot
替换
HotSpot VM
:HotSpot
是一家名为Longview Technologies
的小公司设计,97年被SUN公司收购,JDK1.3
到现在一直是默认虚拟机
HotSpot
的名字就指的是它的热点代码探测技术,占有绝对的市场地位,不管是openJDK
还是oracleJDK
都用的是HotSpot
虚拟机
方法区是HotSpot
独有的,像J9
、JRockit
都没有方法区
JRockit VM
:BEA
公司发布,后被Oracle
收购
JRockit VM
专注于服务器端应用,不关注程序启动速度,关注程序的响应时间,因此JRockit VM
内部没有解释器,全部代码都靠即时编译器编译;大量行业基准测试显示JRockit
虚拟机是世界上最快的JVM
,没有之一;提供毫秒甚至微秒级的响应,在延迟敏感型场景应用广泛
JRockit
的Mission Control
套件比较有用,被Oracle
于JDK8
整合到HotSpot
中形成了现在的JMC
,具体分成了内存泄漏检测器、JVM
的运行时分析器和管理控制台三个独立应用程序,JMC
的主要功能就是监控JVM
的内存泄漏;
J9 VM
:IBM
发布
市场定位和HotSpot
接近,作为服务端、桌面、嵌入式等多用途VM
,广泛用于IBM
的各种Java
产品
在IBM
自家产品上测试速度世界最快,但是通用性和其他产品上的性能比不上JRockit
,而且在windows
场景下使用Bug
很多
2017年,IBM
开源J9 VM
命名为OpenJ9
,交给Eclipse
基金会管理
CDC/CLDC
:oracle
在Java ME
方向发布的两款虚拟机
诺基亚时代的塞班系统,游戏和应用程序就是用Java ME
产品线开发,现在手机被Android
和IOS
二分天下,Java ME
几乎已经失去移动端市场、CLDC
的KVM
产品因为简单、轻量和高度可移植在更低端设备比如智能控制器、传感器和老年机上还在使用
TaobaoJVM
:由AliJVM
团队基于HotSpot
深度定制开源的服务器版JVM
使用GCIH
技术将生命周期较长的Java
对象从堆空间移到堆外,降低了GC的频率并且实现GCIH
中的对象在多个JVM
进程中共享
使用crc32
指令降低对JNI
的调用开销
提供针对大数据场景的ZenGC
Taobao JVM
在阿里产品上性能高,在硬件上严重依赖Intel
的CPU
,损失兼容性,淘宝、天猫的产品全都把Oracle
的JVM
替换成了Taobao JVM
Dalvik VM
:由谷歌发布应用于Android
系统的虚拟机,没有遵循Java
虚拟机规范,不能称为Java
虚拟机,在Android2.2
提供了即时编译器
Android5.0
以前使用的Dalvik VM
,此后替换为支持提前编译技术[AOT]的ART VM
提前编译指可以直接把源文件不经过字节码直接编译成机器指令,执行效率更高
不能直接执行class
格式的字节码文件,执行dex
格式的文件,dex
格式文件可以通过Class
文件转化得到,使用Java
语法编写应用程序,可以直接使用大部分Java API
安卓应用程序都是以.apk
结尾的文件,改成zip
解压,里面有大量.dex
为后缀的文件,就相当于Java
中的.class
文件
采用基于寄存器的指令集架构,执行效率高,和硬件耦合度高
Graal VM
:2018年oracle
发布,在HotSpot
基础上增强而成的跨语言全栈虚拟机,可以作为任何语言的运行平台
支持不同语言中混用对方接口和对象
原理是将语言的源代码编程成虚拟机能识别的类似字节码的中间语言格式,只有Graal VM
取代HotSpot
的希望是最大的
简述JVM
组成结构和各结构功能
JVM
结构
1️⃣:类加载子系统:字节码文件依次经过加载、链接和初始化三个环节被加载到JVM
内存中在运行时数据区的方法区中生成Class
实例
2️⃣:运行时数据区:运行时数据区包含程序计数器、虚拟机栈、本地方法栈、堆区和方法区,方法区和堆多线程共享,虚拟机栈、本地方法栈和程序计数器都是每个线程独一份,运行时数据区对应类为单例Runtime
类
PC
寄存器也叫程序计数器区域:每个线程一份程序计数器
虚拟机栈区域:每个线程一份虚拟机栈,虚拟机栈的基本单元是栈帧[栈帧的内部结构分为局部/本地变量表、操作数栈、动态链接、方法返回地址]
本地方法栈:本地C
类库的方法调用执行栈
堆区:Java
中创建的对象主体都分配在堆区,JVM
中内存最大的一块空间,GC
重点考虑的一块空间,堆区也是多线程共享的资源
方法区:方法区主要存放类信息、常量、域信息、方法信息;方法区是HotSpot
虚拟机独有;JDK7
以前方法区的落地实现叫永久代,JDK7
以后叫元空间[永久代和元空间都是方法区的落地实现]
3️⃣:执行引擎:执行引擎包含解释器、JIT
即时编译器和垃圾回收器,负责将字节码指令翻译成机器指令供CPU
执行
每当执行完一条指令后程序计数器会更新下一条指令地址,执行引擎从程序计数器获取下一条指令;通过局部变量表中的对象引用定位堆中的对象实例;通过对象头的类型指针[也叫元数据指针]定位当前对象的类元数据
JIT
即时编译器将反复执行的热点代码专门编译成机器指令并缓存在方法区方便解释器解释运行的时候直接调用
执行引擎形象解释就是两种语言之间的翻译官
所有JVM
的执行引擎输入的都是字节码二进制流,处理过程是字节码的解析执行过程,输出执行结果
大部分程序转换为物理机或者虚拟机执行的机器指令前,需要经过下面两条路径,其中程序源码--词法分析--单词流--语法分析--抽象语法树由Javac
即前端编译器完成,形成抽象语法树以后会遍历语法树形成线性的字节码指令流,优化器--中间代码--生成器--目标代码是传统编译原理中程序代码到目标机器代码的生成过程,体现Java
半编译型半解释型语言的半编译型;指令流--解释器--解释执行是逐行翻译解释执行的过程,体现Java
半解释型半编译型语言的半解释型,Java
半编译型半解释型语言的根本原因是字节码交给操作系统和CPU
执行时既可以使用解释器,也可以使用即时编译器;Java
一开始没有即时编译器因此最开始就只是解释型语言,后来发展出了可以根据字节码直接生成本地机器代码的即时编译器可以在方法区缓存被翻译的本地代码方便某段代码被频繁调用直接使用缓存的机器指令而无需再被解释器解释翻译,JVM
执行Java
代码时通常都会将解释执行与编译执行二者结合起来进行
前端编译器即Java
源码级编译器对代码的处理流程:源代码--词法分析器--Token
流--语法分析器--语法树/抽象语法树--语义分析器--注解抽象语法树--字节码生成器--JVM
字节码,这部分和JVM
没有关系
JVM
的执行引擎对JVM
的字节码处理流程:
JVM
字节码--机器无关优化--中间代码--机器相关优化--中间代码--寄存器分配器--中间代码--目标代码生成器--目标代码,由JIT
即时编译器负责
JVM
字节码交由字节码解释器逐行解释执行
解释器:对应解释流程指令流--解释器--解释执行,负责根据预定义的规范将字节码指令逐条翻译为所在平台的本地机器指令执行
一条字节码指令被解释执行完毕后,解释器再根据程序计数器中记录的下一条等待被执行的字节码指令执行解释操作
古老的字节码解释器:就是将字节码逐条翻译成字节码指令并执行,效率非常低
现在普遍使用的模板解释器:将每条字节码和一个模板函数关联在一起,通过模板函数直接产生当前字节码执行时的机器码,显著提高解释器的性能
不管使用字节码解释器还是模板解释器凡是基于解释器执行都认为是低效的代名词,也是这个原因被C/C++
程序员调侃,很多语言都有解释器,包括C和C++后面也补充了解释器,但是只有解释器就会成为低效的代名词;JVM
提供及时编译器避免函数被解释执行,将整个函数体编译成机器码并缓存起来,再想执行这部分代码直接使用已经编译好的机器码,通过这种方式大幅提高程序的执行效率,避免冗余的将源码先编译成汇编语言,再将汇编语言汇编成机器语言的过程
解释器的优势是JVM
一启动就能直接拿着字节码开始逐行执行,但是JIT
编译器需要先把一定范围的字节码全部翻译成机器指令以后才能执行,解释器启动快,单条字节码执行慢;JIT
编译器要先编译再执行,启动慢,单条字节码执行快
JIT
即时编译器Just In Time Compiler
:对应编译流程优化器--中间代码--生成器--目标代码,将字节码指令编译成本地机器平台相关的机器指令并缓存起来,并不像解释器一样立即执行
典型优势就是速度快,特别是代码大量复用的场景,就像是提前把菜切好,炒菜的时候不需要处理菜直接用,最重要的是超过的菜还可以复用
编译器可以指前端编译器也可以指后端编译器,前端编译器负责将.java
文件编译成.class
文件,后端运行期编译器就是JIT
即时编译器,负责将字节码转变成汇编再转变成机器码;典型的前端编译器比如JDK
中的Javac
、Eclipse JDT
中使用自己研发的增量式编译器ECJ
;典型的JIT
编译器比如HotSpot
中的C1
、C2
编译器;此外编译器还可以指静态提前编译器AOT
编译器[Ahead of Time Compiler
],这是java
发展的一个趋势
查了一下java
常规编译过程不直接设计汇编,AOT
编译器内部可能涉及汇编的生成和优化
JIT
编译器会根据代码被调用执行的频率判断字节码是否为热点代码,将热点代码进行即时编译
热点代码:被多次调用的一个方法,或者一个方法体内循环次数较多的循环体都可以成为热点代码
栈上替换:这种在方法执行过程中对热点代码的编译方式被称为栈上替换,也称为OSR
[On Stack Replacement
]编译
HotSpot
采用基于计数器的任店探测方式来探测热点代码,HotSpot
会为每个方法都建立方法调用计数器和回边计数器两个计数器;方法调用计数器统计方法的调用次数,回边计数器统计循环体执行的循环次数
方法调用计数器在Client
模式下的默认阈值为1500
次,Server
模式下的默认阈值为10000
次,只要超过该阈值就会成为热点代码触发JIT
编译,该阈值可以通过JVM
参数-XX:CompileThreshold
设置
方法调用时会首先检查当前方法是否已经被JIT
编译过,已经被编译过会直接调用编译后的机器码直接执行,如果还没有被编译过让方法调用计数器加1,检查计数是否超过阈值,没超过由解释器解释执行,超过向JIT
编译器提交编译请求,编译缓存到方法区以后再被调用执行
方法调用计数器统计的是一段时间之内方法被调用的次数,超过一定时间调用次数还没有达到阈值方法调用计数器会减少到原计数的一半,该过程称为方法调用计数器的热度衰减,该时间称为当前方法统计的半衰周期;可以通过配置JVM
参数-XX:-UseCounterDecay
来关闭热度衰减,此时方法调用计数器统计的是方法被调用的累计绝对数,只要系统运行时间足够长,绝大部分方法都会被编译为本地方法;可以通过JVM
参数-XX:CounterHalfLifeTime
设置半衰周期的时间,默认单位为s
回边计数器统计一个方法中循环体代码被执行的次数,回边指字节码中遇到控制流向已经被执行过的指令跳转的指令
遇到回边指令时检查循环体是否已经被JIT
编译过,如果已经编译过执行循环体已经被编译过的本地代码;如果没有编译过,会让回边计数器加1,判断两个计数器的和是否超过阈值,没有超过阈值由解释器解释字节码执行;超过提交OSR
编译请求JIT
编译循环体并缓存再调用机器指令执行
JVM
运行时可以通过命令参数显式指定运行时只采用解释器执行还是只采用即时编译器执行
-Xint
:只采用解释器模式执行程序,JVM
工作在interpreted mode
下
一个简单的几条语句的循环体被执行100万次,纯使用解释器执行时间为6520ms
-Xcomp
:只采用即时编译器模式执行程序,当即时编译出现问题解释器会介入执行,JVM
工作在compoled mode
下
一个简单的几条语句的循环体被执行100万次,纯使用解释器执行时间为950ms
-Xmixed
:采用解释器+即时编译器混合模式执行程序,JVM
工作在mixed mode
下
一个简单的几条语句的循环体被执行100万次,纯使用解释器执行时间为936ms
JVM
内嵌C1
和C2
两个JIT
编译器,C1
也叫Client Compiler
,C2
也叫Server Compiler
,可以通过命令参数显式选择具体使用哪一种即时编译器,64
位的操作系统只支持server
版本,即使指定了-client
也会忽略掉
-client
:指定JVM
运行在Client
模式下,使用C1
编译器
C1
编译器对字节码的优化简单,编译速度快
C1
的优化策略主要有方法内联、去虚拟化、冗余消除;
方法内联:将引用的方法代码编译到引用点处,不再让每次方法的调用都创建栈帧,将所有该方法的调用都指向一块空间
去虚拟化:对接口唯一的实现类进行内联
冗余消除:将运行期间不执行的代码直接折叠掉
-server
:指定JVM
运行在Server
模式下,使用C2
编译器
C2
编译器对字节码的优化更激进深入,编译耗时长,但是优化后的代码执行效率更高
C2
的优化是基于逃逸分析的优化,包括标量替换、站上分配和同步消除
但是Server
模式下也不是只用C2
不用C1
,JVM
采用分层编译策略,不开启性能监控的情况下程序使用C1
编译进行简单优化,开启性能监控后使用C2
根据性能监控信息进行激进优化,JDK7
以后,Server
模式默认就开启了分层编译策略即开启了性能监控;C2
编译器启动时长比C1
长,但是系统稳定后,C2
编译器编译出来的代码执行速度远远快于C1
编译器
一般JIT
编译出来的机器码性能比解释器高
JDK10
开始HotSpot
加入一个Graal
即时编译器,对应有一个Graal VM
,该编译器的编译效果已经赶上了C2
编译器,目前还带实验标签,即不同的版本可能修改或者移除,带有一个实现标识,可以通过JVM
参数-XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler
激活使用,Graal
和C2
并列的关系,Graal
属于即时编译器
JDK9
引入AOT
静态提前编译器,AOT
是和JIT
并列的关系,是与即时编译对立的概念,Java9
引入实验性的AOT
编译工具jaotc
,在用户运行程序前可以手动将字节码文件编译成.so
文件,直接就将字节码编译成机器码
优势是JVM
在启动之前就将字节码预编译成二进制库,JVM
可以直接加载预编译的二进制机器码直接执行,无需预热,启动就执行机器指令
缺点是打破了Java
的跨平台特性,同一份.so
文件不能在不同硬件和操作系统上执行;丧失Java
链接的动态性,运行前就需要明确所有的机器指令,JVM
运行前需要明确所有的代码,失去Java
的动态链接特性;目前AOT
编译器仅支持Linux 64
位
HotSpot
采用解释器和即时编译器并存的架构,JVM
运行时解释器和即时编译器相互协作,由HotSpot
决定何时使用解释器,何时使用即时编译器
JRockit
因为主攻服务端市场,服务端应用对启动时间不关注,重点关注响应时间,完全抛弃解释器的JRockit
启动时也必须花费更长的时间来对字节码进行编译,但是换来了高效的执行性能
客户端市场对启动时间也有一定的需求,比如IOS
的启动时间快,图标一点就有反应,安卓虽然功能强大,但是很多时候点图标半天才有响应;因此对于看中启动时间的应用场景,需要采用解释器与编译器并存的架构来在启动时间和响应时间之间找一个适用于场景的平衡点
解释器与即时编译器并存的架构在热机状态下相较于冷机状态能承受更大的负载,如果以热机状态的可承载流量进行切流,可能导致处于冷机状态下的服务器因为无法承载住流量而假死;典型的比如生产环境发布过程的分批发布,一般将正在运行的机器数量划分为N个批次,每次对其中一个批次的机器进行发布更新,一般每个批次的机器数最多站总集群的1/8
;阿里有过一个案例,一程序员在发布平台填写总发布批数的时候因为认为热机状态下一半的机器就能承载当前负载,就选择了分两批发布,结果第一次成功更新后的JVM
刚启动都是解释执行,还没有对热点代码进行统计和即时动态编译,停掉另一半的机器此时刚发布成功后的机器全部处于冷机状态,无法承载当前的流量,导致第一批发布成功的服务器全部宕机;
此外,阿里的限流框架Sentinel
对资源的流控规则也设计有预热模式,当系统流量长期处于低水位的情况下,流量突然增加直接把系统拉升到高水位也可能瞬间把系统压垮。预热模式在QPS超过某个阈值默认值为3的情况下通过限制通过的流量,让流量在一定时间内默认值是5s逐渐增加到预设的流量阈值上限的冷启动方式,给冷机一个预热的时间,避免冷机被突增的高流量压垮压垮。使用这种限流框架也能防范上述分批发布冷热机切换存在的风险
4️⃣:本地方法接口:包括本地方法接口JNI
和本地方法库,在Java
核心API中有一些没有方法体被native
关键字修饰的方法,这些方法就是本地方法,实际上这些方法有方法体,只是底层已经用C/C++
实现了;使用native
关键字修饰的Java
方法就是本地方法;native关键字和abstract
关键字不能共用;本地方法主要是为了在Java
平台在某些场景下使用C/C++
来完成这些任务
本地方法存在的主要原因是与Java
外的环境交互,和操作系统以及一些硬件交互时必须使用C/C++
,本地方法提供由C/C++
实现的接口来供Java
直接调用,操作系统主要还是由C/C++编写的,JVM
不是一个完整的操作系统,必须依赖于操作系统的支持,Java
通过C
实现的本地方法和操作系统进行交互,此外Java
中的一些方法需要直接和操作系统打交道这些方法都是由C
实现的[类似于老美制定的国际标准,完全不使用这套标准还是有一定困难的]
Sun
的解释器是用C实现的,jre
大部分是用Java
实现的,部分方法是用C实现并植入JVM
内部,在底层很多的本地方法都是由外部的动态链接库提供被JVM
调用
以前与硬件交互比如由Java
驱动打印机或者管理生产设备还需要用户编写本地方法,现在随着Java
的发展,这种现象已经很少见了
JVM内存区域解析[内存区域就是运行时数据区,其中最重要的是虚拟机栈、堆和方法区]
线程:线程是一个程序的运行单元,操作系统中有很多进程,单个进程中有很多线程,JVM
中每个线程对象都和操作系统的本地线程存在一一对应关系;Java线程准备好执行[准备过程是准备线程对应的程序计数器、虚拟机栈、本地方法栈等]调用start
方法后本地线程才会创建,本地线程创建并初始化成功后,操作系统就会调用Java
线程中的run
方法,Java
线程执行结束后本地线程也会对应回收,如果Java
线程出现异常Java
线程会直接结束,本地线程还会决定JVM
进程是否终止,判断依据是判断当前线程是否JVM
进程的最后一个用户线程
HotSpot VM
后台运行的守护线程主要有以下几类[通过jconsole
可以查看这些线程]
虚拟机线程:JVM
运行达到安全点,虚拟机线程负责stop-the-world
垃圾收集、线程栈收集、线程挂起以及偏向锁撤销
周期任务线程:负责周期性任务的调度执行
GC线程:专门对JVM
中不同类型的垃圾进行回收
编译线程:将字节码编译成本地机器指令
信号调度线程:接收信号并发送给JVM
,JVM
通过该线程对事件进行适当处理
运行时数据区:运行时数据区包含程序计数器、虚拟机栈、本地方法栈、堆区和方法区,方法区和堆多线程共享,虚拟机栈、本地方法栈和程序计数器都是每个线程独一份,运行时数据区对应类为单例Runtime
类
PC
寄存器也叫程序计数器区域:程序计数器是从软件层面对CPU寄存器的抽象模拟[CPU只有把数据装载到寄存器中才能运行],也被称为程序钩子
功能是存储虚拟机栈中栈帧[栈帧就是线程执行的当前方法]的操作数栈中的下一条将被执行的指令的指令地址/偏移地址,执行引擎根据该内存地址去虚拟机栈的局部变量表和操作数栈读取下一条字节码指令;如果当前线程正在执行本地C类库方法,程序计数器的值为undefined
注意后面又变了,老师和《深入理解Java虚拟机》中都说程序计数器记录的是当前正在被执行指令的指令地址,弹幕说操作系统中CPU里面的程序计数器指向的是下一条指令,JVM
规范中明确指出这里的程序计数器指向的是当前正在被执行的指令
每个线程一份程序计数器,生命周期与线程生命周期保持一致
PC寄存器没有垃圾回收的概念,也不会发生OOM
内存溢出风险;虚拟机栈和本地方法栈没有垃圾回收,但是有可能会发生OOM
风险;堆区和方法区有垃圾回收也可能发生OOM
风险
虚拟机栈区域:每个线程一份虚拟机栈,虚拟机栈的基本单元是栈帧[栈帧的内部结构分为局部/本地变量表、操作数栈、动态链接、方法返回地址],每个栈帧对应一个方法
指令集架构模型[有基于栈的指令集架构和基于寄存器的两种指令集架构]
基于栈的指令集架构
每执行一个方法就对方法做一次栈帧的入栈操作,方法执行完以后做一次栈帧的出栈操作
基于栈的指令集架构一般都是零地址指令
基于栈的字节码指令以每八位单字节的方式对齐,基于寄存器的指令以十六位双字节的方式对齐;单个指令字节数更小,总的指令数量更多
特点[跨平台、指令集以零地址指令为主,单条指令占用空间小、编译器容易实现,缺点是基于内存相对性能不高,同样的功能需要更多的指令]
基于寄存器的指令集架构
x86
的二进制指令集和传统PC
以及安卓的Davlik
虚拟机都是基于寄存器的指令集架构
特点[基于CPU高速缓冲区性能高,与硬件耦合度高可移植性差,指令集以一、二、三地址指令为主,单条指令占用空间大,但同样功能需要的指令数更少]
堆和栈的区别
栈是运行时的单位解决程序如何执行的问题、堆是存储的单位解决数据放在哪儿怎么放的问题[也不绝对,对象在堆中,基本数据类型的局部变量还是在栈中,对象作为局部变量栈中只是存放的对象引用而不是对象本身]
虚拟机栈主管Java
程序的运行,保存方法的局部变量、部分结果,参与方法的调用和返回
栈的特点
栈是仅次于程序计数器的快速分配存储方式,只针对栈顶对栈帧压栈出栈,操作简洁;因为栈的工作特性也不存在垃圾回收问题,可能存在OOM
虚拟机栈[早期叫Java
栈]在每个线程创建时都会创建一个私有的虚拟机栈,虚拟机栈的基本单元是栈帧,一个栈帧对应一次Java
方法的调用,虚拟机栈生命周期与线程一致
如果在线程创建时指定了虚拟机栈的容量,如果线程实际分配的栈容量超过虚拟机栈的容量最大值,Java
虚拟机会抛出一个StackOverflowError
异常
如果虚拟机栈的容量设置为可动态扩展,只有在虚拟机栈尝试扩展时无法申请到足够内存或者创建新线程时没有内存去创建虚拟机栈时,虚拟机会抛出OutOfMemoryError
异常
虚拟机栈的大小可以通过虚拟机系统参数VM options
指定为-Xss256k
来设置,默认虚拟机栈的大小约1M,要避免虚拟机栈的容量大小低于单个线程运行需要的容量,否则会报栈溢出异常,但是虚拟机栈可以动态扩展,这点没有考虑进来
虚拟机栈结构
虚拟机栈的基本单位是栈帧,一个栈帧对应一个方法,栈帧维护方法执行期间的各种数据信息,虚拟机栈对栈帧压栈和出栈
一个活动线程一个时间点上只有栈顶栈帧是活动有效的,称为当前栈帧;当前栈帧对应方法称为当前方法;定义该方法的类称为当前类
执行引擎只运行当前栈帧中的字节码指令,程序计数器中存储的也是当前栈帧中的指令地址
当前方法返回时,当前栈帧会将当前方法的恶之星结果返回给前一个栈帧
Java
方法有两种返回函数的方式,一种是正常的函数返回,一种是抛出未被处理的异常,两种方式都会使当前栈帧被弹出
栈帧结构
栈帧由局部变量表、操作数栈[表达式栈]、动态链接[也称指向运行时常量池的方法引用]、方法返回地址[也称方法正常或异常退出的定义]、一些附加信息五部分组成,重点是局部变量表和操作数栈[栈帧的大小主要就取决于局部变量表和操作数栈的大小],动态链接、方法返回地址和一些附加信息也被称为帧数据区
局部变量表:局部变量表是一个数字数组,存储方法参数和定义在方法体中的局部变量,存储的数据类型为基本数据类型、对象引用和方法返回值地址,存储的数据主要是非静态方法的this
指针、形参、方法内部定义的局部变量值,注意不是变量名
基本数据类型中byte
、short
、long
、int
、double
、float
本身就是数字,char
也有对应的ASCII码或Unicode码,存储前会被转换为int
,boolean
存储前也会被转成int
,八种数据类型都可以用数字表示
局部变量表线程私有,不存在数据安全问题
一个方法的局部变量表的容量在编译阶段就确定了,保存在方法的Code
属性的maximum local variables
属性中,Code
属性中的Code length
表示方法赌赢字节码的指令行数,方法运行期间不会改变局部变量表的大小;通过javap
指令和jclasslib
插件都能查看一个类中所有方法的局部变量表信息和相应的作用字节码范围,局部变量表中的变量按照方法中的声明顺序排列
局部变量表的基本单位是变量槽Slot
,占32个比特位的数据类型即除long
和double
的其他数据类型包括对象引用只占一个Slot
,long
和double
占用两个Slot
,即一个Slot
占32个比特位四个字节;使用变量起始位置的索引对变量进行引用
实例方法和构造方法中可以调用变量this
指代当前对象,这是因为实例方法和构造方法在创建时,当前对象的引用this
会存放在局部变量表的第一个变量槽中,而静态方法的局部变量表中没有该操作,因此静态方法中不允许使用this
方法中没有使用变量接受一个有返回值的被调用方法执行结果,当前方法的局部变量表中不会为被调用方法的返回值分配空间
一张局部变量表中的变量槽中的变量如果作用域比方法的作用域小,在作用域后面声明的局部变量会在变量槽失效后占用该变量槽来节省空间,这中占用关系在编译时就决定好了
局部变量表中的变量是垃圾回收的根节点GC Roots
,只要被局部变量表直接或间接引用的对象都不会被回收,因此局部变量表也是性能调优重点关心的区域
方法调用时形参的传参就是通过局部变量表来进行传递的
操作数栈:也叫表达式栈,操作数栈根据字节码指令被压栈或者出栈字节码指令操作的数据,JVM
的执行引用是基于操作数栈的执行引擎,操作数栈管理字节码执行过程中的所有数据,此外执行引擎还会将字节码指令翻译成机器指令结合操作数栈的数据交给CPU执行,并将CPU的执行结果再存入操作数栈中;操作数栈用于保存计算过程的中间结果,同时作为计算过程中变量的临时存储空间;操作数栈的作用是存放字节码指令的操作数和返回结果;执行指令前,JVM
会将指令的操作数压入操作数栈,执行时将指令需要的一个或多个操作数弹出,指令执行结束后将执行结果重新压入栈中;操作数栈中弹栈压栈的是变量值
操作数栈在栈帧创建时一同创建,操作数栈是一个容量确定的数组,具体容量在编译时就会被定义,通过方法的Code
属性中的max_stack
属性值能查到操作数栈的具体容量;对局部变量初始化时会先根据字节码指令将数据存入操作数栈,再从操作数栈将数据出栈存入局部变量表;对数据进行运算会先从局部变量表将数据压栈到操作数栈,再从操作数栈弹栈交给CPU
执行,将执行结果压栈到操作数栈,再从操作数栈弹栈存入局部变量表
long
和double
类型的数据占两个栈单位深度,此外其他类型32位占一个栈单位深度;byte
、short
、char
、boolean
都会以int
类型来保存
如果被调用的方法有返回值,被调用方法返回后返回值会从上一个栈帧的操作数栈弹栈然后被直接压入当前栈帧的操作数栈中
栈顶缓存技术[ToS]:因为频繁的入栈出栈操作导致更多的指令分派和内存读写次数从而降低程序的执行速度,HotSpot VM
的设计者提出栈顶缓存技术,原理是将栈顶元素全部缓存在物理CPU寄存器中,减少数据的频繁入栈出栈操作,让CPU
直接操作寄存器中缓存的数据,减少压栈弹栈次数
动态链接:动态链接保存着当前栈帧指向方法区中运行时常量池中对应方法的符号引用,该符号引用的目的是为了让当前方法的代码能实现动态链接,动态链接对应静态解析,下面主要介绍方法调用
Java
源文件中的所有变量和方法引用在被变成字节码时会被编译成符号引用保存在字节码文件的常量池中,字节码文件中专门有一个区域称为常量池,字节码文件被加载到方法区以后常量池对应的是运行时常量池,返回值为空这个空值也存在于常量池中,凡是返回空参的方法都会引用该常量池中的空参;类变量比如int
类型对应的常量I
也会存在常量池中,符号引用就是字节码文件常量池中的#数字
的标识,符号引用可以指向一个常量本身也可以指向其他常量的符号引用,常量池就是为了提供符号和常量,节省资源,使用的时候直接通过符号来识别具体的资源并进行调用
字节码文件将类用到的类、类中定义的方法、类变量、字符串等存入常量池并生成对应的符号引用,字节码指令通过符号引用在常量池中找到对应的信息来进行使用,比如在一个方法中描述调用了另外的方法,这是为了同一份资源的共用,节省资源,同时通过方法引用实现子类调用父类的方法引用来实现多态的设计思想
静态链接:字节码文件装载到JVM
时如果其中的具体方法在编译阶段已知且运行期间保持不变,在编译期间就能确定符号引用和直接引用的对应关系就称为静态链接
早期绑定:在编译期间就能确定被调用的具体方法内容,此时符号引用就能直接指向方法、字段或者类的直接引用,此时就会使用静态链接的方式,典型场景就是不使用多态或者super.eat()
指定调用父类中的eat()
方法,指定调用父类的构造方法super()
,单参构造通过this()
调用无参构造
面向过程的语言只有早期绑定、面向对应的语言都支持封装、继承、多态,都同时具备早期绑定和晚期绑定两种方式;Java
中任何一个方法都可以拥有C++
中虚函数的特征,即多态通过父类型引用指向子类型实例来实现对子实例方法的调用,如果不希望某个方法拥有虚函数的特征可以使用final
关键字来进行标记,即多态中对final
修饰方法的调用还是调用的父类的对应方法引用,对应的final
修饰的方法不能被子类重写,这是对多态的理解
非虚方法:编译期间就确定了被调用方法的具体版本,且运行时不会发生改变的方法,静态方法、私有方法、final
修饰方法、实例构造器、通过super
调用父类方法都是非虚方法,除了这五种情况剩下的方法都是虚方法;字节码中invokestatic
和invokespecial
指令是调用非虚方法的指令,invokestatic
调用静态方法,invokespecial
调用构造器、私有或者调用父类方法;invokevirtual
和invokeinterface
调用所有虚方法以及final
修饰的非虚方法,final
修饰非虚方是通过invokevirtual
调用的;此外还有一个invokedynamic
指令可以动态解析出需要调用的方法,该指令在jdk7
引入,目的是为了实现动态类型语言,jdk7
需要使用ASM
字节码工具来生成invokedynamic
指令,jdk8
的Lambda
表达式让该指令可以直接在方法执行过程中动态生成
静态类型语言:Java
语言本身是静态类型的语言,有了invokedynamic
指令后Java
语言就具备了动态类型语言的特性,在编译期间就对数据类型检查的语言是静态类型语言,如果数据字面值不满足变量的指定类型编译就会报错,静态类型语言判断变量自身的类型信息
动态类型语言:在运行期间对数据类型检查的语言是动态类型语言,像JS
和Python
就是动态类型语言,动态类型语言判断的额是变量值的类型信息,变量没有类型信息、变量值才有类型信息;只能根据变量值才能确定一个变量是什么类型的,虚拟机引入该invokedynamic
是为了在JVM
上支持动态类型语言的运行
动态链接:方法只能在程序运行期间将符号引用转换成直接引用称为动态链接
晚期绑定:编译期间不能确定被调用的具体方法,只能在运行期间根据实际类型确定被调用的具体方法,就会通过动态链接的方式来实现符号引用指向直接引用,典型场景就是多态[子类对象多态性的前提是类的继承关系和方法的重写]
方法重写的本质:通过方法调用的字节码指令将执行对象实例的实际类型压栈到操作数栈栈顶,通过该类型去常量池中找名字描述一致的方法,并进行权限校验,校验通过直接找到方法的直接引用,校验不通过抛出IllegalAccessError
异常;按照继承关系从下往上依次对该类型的和各个父类进行上述常量池搜索和校验的过程直到找到具体的方法,如果遍历到最顶层的类或者接口还没有找到具体的方法就会抛出AbstractMethodError
抽象方法异常;为了避免频繁动态分派在类的方法元数据中搜索合适的目标来提高性能,JVM
在类的方法区建立一个虚方法表,虚方法表中存储着各个虚方法的实际入口,只要虚方法被动态分配一次,后续调用就能直接通过虚方法表找到对应的方法入口而不再需要进行搜索,虚方法表在类加载的链接环节的解析步骤被创建并开始初始化,类变量初始化完成后虚方法表也被初始化完毕,子类重写过的虚方法在虚方法表中执行子类自身的虚方法
方法返回地址:方法返回地址保存调用当前方法的方法调用当前方法时的程序计数器的值,即调用当前方法的方法的下一条指令,正常执行结束的方法通过方法返回地址来确定上一个方法下一条应该被执行的指令;说白了就是退出当前方法前先将PC寄存器中的值设置为当前方法方法返回地址中的值,让当前线程去执行上一个方法的下一行代码
特别注意异常退出当前方法通过异常表来确定上一个方法下一条应该被执行的指令
异常表就是在指定字节码行号范围内出现的异常根据异常类型匹配执行指定异常对应字节码指令起始行号指令,异常表可以通过字节码中对应方法的Exception table
来查询
正常退出会给调用者返回当前方法的返回结果,但是异常退出不会给上层调用者返回任何结果,没有被处理的异常会被抛给上层调用者
当前方法正常调用完成后需要根据返回值的实际数据类型来确定使用哪一个返回指令,boolean
、byte
、char
、short
、int
对应的返回指令为ireturn
,long
对应指令为lreturn
,float
对应指令为freturn
、double
对应指令为dreturn
,引用类型对应指令为areturn
,void
、构造器、类和接口的<clinit>()
方法对应的指令为return
一些附加信息
附加信息不同的JVM
实现允许携带的附加信息不同,比如设置对程序调试提供支持的信息
本地方法栈:本地C
类库的方法调用执行栈,虚拟机栈管理Java
方法的调用,本地方法栈管理本地方法的调用,本地方法指被Java语言调用用C实现的方法
本地方法栈是线程私有的,本地方法栈也可以设置固定容量或者设置为可动态扩展内存,同样固定容量实际使用量大于固定容量抛栈溢出异常,动态扩展容量没有足够内存扩容或者创建本地方栈抛内存溢出异常
Java
调用本地方法时会将本地方法以栈帧的形式压入本地方法栈,通过动态链接的方式由执行引擎调用本地方法库
本地方法可以直接通过本地方法接口访问虚拟机内部的运行时数据区,还可以直接使用本地处理器中的寄存器和随意使用本地内存
不是所有的JVM
都支持本地方法,JVM
规范没有要求本地方法栈,HotSpot
虚拟机将本地方法栈和虚拟机栈合二为一,直接通过动态链接的方式在本地方法库找到本地方法由执行引擎来执行
堆区:Java
中创建的对象主体都分配在堆区,JVM
中内存最大的一块空间,GC
重点考虑的一块空间,堆区也是多线程共享的资源,堆区分为新生代和老年代
概念
一个JVM
实例[一个JVM
进程只能运行一个主方法]只有一个堆内存,堆是Java
内存管理的核心区域;JVM
启动时通过引导类加载器创建运行时数据区的同时创建堆区,堆一创建容量就确定了,堆空间容量可以通过参数-Xms10m -Xmx10m
调节即初始堆空间和最大堆空间都是10M[参数在执行java
指令运行字节码的时候指定],堆是JVM
中最大和最重要的一块内存空间
堆可以处于物理上不连续逻辑上连续的内存空间,不连续的物理内存可以通过映射表在逻辑上视为连续的一块内存
堆空间被所有线程共享,但是每个线程还可以在堆空间开辟线程私有缓冲区即TLAB
,多线程并发为了共享数据的安全性需要同步保证对共享数据操作的原子性,会降低系统的性能,TLAB
在保证数据安全性的同时保证线程并发性能
几乎所有的对象实例和数组都应该分配在堆上,JVM
为了提高性能,引入了逃逸分析,如果方法创建的对象没有发生逃逸可以把对象分配在虚拟机栈上
对象使用完毕不会马上从堆中移除,仅在垃圾回收阶段才会被移除,这是为了避免频繁GC影响用户线程的执行降低系统性能[GC会暂停JVM
中的所有线程];堆是垃圾回收的重点区域,在大内存对象和频繁垃圾回收的情况下垃圾回收会成为系统性能的瓶颈
创建对象的字节码指令是new
,创建数组的字节码指令是newarray
堆区通过系统参数-Xms
或-XX:InitialHeapSize
设置堆区的起始内存,通过系统参数-Xmx
或-XX:MaxHeapSize
设置堆区的最大内存
-X
表示这是jvm
的运行参数,ms
表示memory start
初始内存大小,默认单位是字节数,k
或者K
表示KB
,m
或者M
表示MB
默认情况下,初始内存大小是物理电脑内存大小的1/64
,最大内存为物理电脑内存大小的1/4
;通常-Xms
和-Xmx
会配置相同的值,目的是为了在垃圾回收清理完堆区后不需要重新计算堆区的大小[因为最大容量和初始容量不同系统每次垃圾回收后都会重新计算应该给堆区分配多少内存,消耗系统性能],提高系统性能;注意这个物理内存是可用内存,实际上操作系统还会使用约几百M的内存,这部分内存对用户来说是不可用的
通过Runtime.getRuntime().totalMemory()
或者Runtime.getRuntime().maxMemory()
可以分别获取当前虚拟机的堆内存容量和最大堆内存容量
jstat -gc 进程号
命令可以查看JVM
在GC
时的统计信息,其中OC
是老年代的总堆内存、OU
是老年代已经使用了的内存,对应的EC
、S0C
、S1C
分别对应伊甸园区、幸存者0区、幸存者1区的内存
注意幸存者0区和幸存者1区只能二选一使用,始终有一个空间是空的,JVM
统计堆的总大小的时候只会总计其中一个幸存者区的大小,因此实际上堆区的大小比JVM
统计的值要略大
在jvisualVM
上的抽样器选项卡的抽样属性的内存中能看到每种类型数据占用的容量
堆内存细分
现代垃圾收集器大部分基于分代收集理论设计,对应分代收集算法
分代的唯一理由就是优化GC
性能,如果没有分代,每次垃圾回收都要判断所有的对象是否需要垃圾回收,而不是降低对长生命周期对象垃圾回收垃圾判定的频率,就会导致每次垃圾回收的效率都会越来越低
JDK7
以前将堆逻辑上分为新生代、老年代和永久代;JDK8
以后将堆逻辑上分为新生代、老年代和元空间
新生代也叫新生区、年轻代;老年代也叫养老区、老年区
新生代分为伊甸园区、幸存者0区、幸存者1区[幸存者区也叫from
区或to
区]三部分,注意每个幸存者区都可能是from
区和to
区
注意逻辑上的堆空间包含新生代、老年代和元空间,实际上的堆空间容量只包含新生代和老年代两部分的总容量
通过系统参数-XX:+PrintGCDetails
能在程序运行的时候打印当前JVM垃圾回收器的细节,该参数配置以后在程序运行结束后才会在控制台打印,会展示堆区和方法区的容量细节
JDK8
永久代变化为元空间也引起了Stringtable
字符串常量池和静态域结构的变化
Java
对象可能是瞬时对象也可能极端情况下生命周期和JVM
一样长,像连接对象的生命周期就会长一些;
为了避免每次GC都判断生命周期很长的对象是否进行回收,将这些生命周期可能很长的对象放入老年代来降低GC判断的频率
几乎所有对象最初都在伊甸园区创建,一定条件下伊甸园区的对象还没有销毁就存入幸存者0或者幸存者1区;一定条件下幸存者区的对象还没有销毁就存入老年代
只有创建的对象特别大,大到伊甸园区都放不下,此时就会直接在老年代创建该对象
IBM
公司专门研究表明新生代中80%
左右的Java
对象的销毁都在新生代进行
JVM
参数-XX:NewRatio=2
可以设置新生代老年代的内存比例,该参数示例表示新生代占1份,老年代是新生代的两倍,新生代占整个堆区的1/3
,这也是JVM
的默认配置,实际生产该参数一般不会调整,只有非常明确系统中很多对象的生命周期都很长才会将老年代的比例调大一些
jps
指令和jstat
指令配合或者使用jvisualvm
都能查看堆区的细分区域大小
jinfo -flag NewRatio 进程号
指令能查看指定进程JVM
参数NewRatio
的值
可以通过JVM
参数-Xmn
设置新生代的最大内存大小,该参数优先级大于-XX:NewRatio=2
,两者冲突的情况下会优先采用-Xmn
的设置,总的堆容量减去新生代的容量得到老年代的容量
默认情况下HotSpot
虚拟机中,伊甸园区和两个幸存者区的内存空间占比JVM
官方文档说是8:1:1
,但实际上不是8:1:1
,此外还可能受JVM
的自适应机制影响,通过JVM
参数-XX:-UseAdaptivePolicy
可以关闭自适应机制[开启内存分配自适应还可能导致两个幸存者区不一样大],-Use
的-
表示不使用,如果替换成+
表示使用;但是实际配置关闭自适应机制以后没有实现对应效果还是原样;要想完全自定义伊甸园区和幸存者区的比例需要使用JVM
参数-XX:SurvivorRatio=8
来自定义设置伊甸园区是单个幸存者区容量的8倍
方法区:方法区主要存放类元信息、常量池、域信息、方法元信息;方法区是HotSpot
虚拟机独有;JDK7
以前方法区的落地实现叫永久代,JDK7
以后叫元空间[永久代和元空间都是方法区的落地实现],此外方法区还缓存JIT
即时编译产物
方法区也是存在垃圾回收的,但是JVM
规范并没有强制要求所有虚拟机都要实现方法区垃圾回收,元空间一般来说空间占用是比较稳定的,GC
不像方法区那么频繁;方法区主要存放运行时常量池,类的属性和方法数据,方法和构造器的字节码[JDK8及以后运行时常量池移动到堆中了],以及类或者对象实例初始化时使用到的特殊方法比如<clinit>
方法或者<init>
方法等
方法区在运行时数据区创建时启动,在JVM
进程结束时销毁,JVM
规范指出将方法区逻辑上看做堆的一部分,但是具体的实现可以选择不对方法区进行垃圾回收和压缩。堆区是要求要垃圾回收,以及为了避免存储碎片的问题会对存储空间进行整理和压缩;JVM
规范不限制方法区的位置和方法区的管理策略;方法区可以设置成固定大小,也可以在运行时动态的扩缩容,方法区的内存和堆一样不要求是物理上的连续空间;HotSpot
的方法区还有一个别名叫非堆,目的就是要将方法区和堆分开,可以认为JVM
规范逻辑上认为方法区是堆的一部分,但是实际的实现因为不进行GC和压缩,要把方法区和堆区分开
方法区大小决定系统可以保存多少个类,如果系统定义的类太多,可能会导致方法区溢出跑OOM
异常,方法区在JDK7
及以前的实现叫永久代,JDK8
以后叫元空间;默认加载的类就非常多,随便写一个简单的代码,加载的类的数量就能达到1674
个左右,加载大量的第三方jar
包比如Tomcat
部署30到50个工程就可能出现方法区溢出的问题;大量地动态生成反射类也可能会造成类过多导致方法区溢出
JDK8
中类的元数据存储在被称为元空间的本地内存[非JVM
内存,就是本机的可用物理内存]中,方法区和永久代不能等价,BEA
的JRockit
和IBM
的J9
中不存在永久代的概念,JDK7
以前永久代还是使用JVM
的内存,可以通过参数-XX:MaxPermSize
设置永久代的大小,JVM
的内存空间有限,容易抛OOM
异常,像JRockit
和J9
一样使用本地内存方法区的内存可以达到几个GB,不容易出现OOM
;JRockit
是世界上公认的速度最快的虚拟机,HotSpot
和JRockit
合并的时候内存不统一,采用了JRockit
更好的方法区设计;元空间和永久代本质的区别就是元空间使用本地内存,永久代使用JVM
内存,此外还更改了一些细节比如将运行时常量池和静态变量移动到堆空间
JDK7
以前的版本可以通过-XX:PermSize
来设置永久代的初始空间,默认是20.75MB
;通过-XX:MaxPermSize
来设定永久代的最大可分配空间,32位操作系统默认是64MB
,64位操作系统默认是82MB
,加载的类信息容量超过该最大可分配空间会抛OOM
异常;这两个参数在JDK8
中已经被废弃了,如果在8中主动设置在程序运行结束后会提示参数已移除
元数据区大小可以使用参数-XX:MetaspaceSize
和-XX:MaxMetaspaceSize
指定,默认值和操作系统平台有关,windows
平台默认情况下初始空间约21MB
,最大可分配空间值为-1
,表示没有限制,即以本地内存的最大值作为方法区的最大值
一旦系统的方法区达到初始空间大小就会触发Full GC
卸载没用的类,对应的类加载器被释放,然后根据GC
释放的容量大小来决定方法区新的内存空间,如果释放的少,就适当的提高;如果释放的空间过多,则会适当降低该值;初始值太低会频繁触发Full GC
,建议将方法区设置的尽量高一些
虚拟机栈中存放对象的引用,引用指向堆空间中的对象实例,对象实例中存放了一个到对象类型数据的指针,指针指向创建对象实例的class
对象
方法区的演进
JDK1.6
及以前经典方法区主要存储被虚拟机加载的类型信息[域信息、方法信息]、运行时常量池、静态变量、即时编译器编译后的代码缓存,06
年建立OpenJDK
的时候就有更改永久代的想法,Oracle
在08
年收购了JRockit
虚拟机,在14
年发布JDK8
的时候将永久代移除,因为方法区的具体实现不受JVM
规范约束,因此不同的Java
虚拟机实现并不统一
JDK1.7
将字符串常量池和静态变量从永久代移动到堆中,永久代字符串常量池存在运行时常量池中,后续只是将字符串常量池移到堆中,运行时常量池还是在永久代中
将StringTable
转移到堆空间的原因:Full GC
只有在老年代空间不足,永久代空间不足时才会触发,这就导致字符串常量池的回收效率不高,开发中一般都会有大量的字符串被创建,如果字符串常量池的回收效率低,很容易就会导致永久代的内存不足;将字符串常量池放在堆中是为了即时回收常量池中的内存
静态变量转移到堆中,注意静态变量的值如果是一个对象,该对象还是被创建在堆区,JDK1.6
及以前静态变量对应对象创建在永久代中,对象的引用指向堆中的值对象,JDK9
开始引入了一个监控JVM
进程的jhsdb
工具;通过Inspector
可以查看class
对象实例,发现静态变量随着Class
对象存放在一起存储在堆区,只是类的元数据包含类的方法代码、变量名、方法名、访问权限、返回值等才是存放在方法区
成员变量本身是随着创建的对象一起存放在堆区,局部变量本身存放在栈帧的局部变量表中,创建的对象存放在堆中;
JDK1.8
及以后,废弃了永久代,将类型信息、字段、方法、常量保存到元空间
使用元空间替换永久代的原因:
实际生产中永久代的空间大小根据项目情况很容易发生变化,项目引入的Jar
包过多,项目的功能点较多,都会动态增加系统需要加载的类数量,永久代受到JVM
内存的限制很容易出现OOM
错误,空间小也容易导致频繁Full GC
,本地内存默认最大值为-1
,即没有限制,物理内存足够就可以敞开用,还可以避免由方法区内存不足引起的频繁Full GC
方法区的垃圾回收主要回收废弃的常量和不再使用的类,判断类不再进行使用是一个很复杂消耗很高的操作,对方法区的垃圾回收性能很低
JDK8
以后运行时数据区变化不大,主要变化在执行引擎的GC垃圾回收器上
方法区的内部结构
方法区主要存储已经被虚拟机加载的类型信息[域信息、方法信息]、运行时常量池、静态变量、即时编译器编译后的代码缓存,JDK8
以后JVM
规范要求字符串常量池和静态变量转移到堆中,类型信息、字段、方法、常量保存在本地内存的元空间中
类型信息
类的全限定类名
当前类的直接父类的全限定类名,接口和Object
类没有父类
类型的修饰符
当前类实现的直接接口的有序列表、
域信息
域名称、域类型、域修饰符、域的声明顺序
方法信息
构造器
方法名称、返回值类型、参数的个数和类型的有序列表、方法的修饰符、方法的字节码、操作数栈的深度、局部变量表的长度
异常表
如果方法有异常,会在异常表中记录每个异常处理的开始位置[try的下一行]、结束位置[整个try语句块的最后一行],出现异常时的下一行即catch
语句那行
类变量
静态变量和类关联在一起,随着类的加载而加载,逻辑上属于类数据,被所有实例对象共享,即使没有任何类实例被创建依然可以直接访问类变量[一个对应类型的空指针也可以直接访问指定的类变量而不会抛空指针异常]
使用final
修饰的类变量被称为全局常量,全局常量在编译期间就被赋值,类变量在类加载期间的链接环节的准备阶段才分配内存赋默认值,在类加载的初始化环节才显示赋值
运行时常量池
字节码文件中的常量池加载到方法区以后成为运行时常量池,常量池表包含了各种字面量以及对类型、域和方法的符号引用,符号引用的格式为#数字
,常量池相当于一张表,用于存放编译期间生成的各种字面量和符号引用,虚拟机指令根据符号引用去常量表中找到要使用的类名、方法名、参数类型、字面值等数据
每个类都会对应一个运行时常量池,类或者接口被加载到虚拟机以后就会创建维护一个对应的运行时常量池,池中的数据和数组一样通过索引进行访问,索引从01
开始,到常量池数量减1
结束;运行时常量池中存放明确的数字字面值,并将运行解析后才能获取的方法或者字段的真实引用替换原来的符号引用,因此运行时常量池相较于常量池因为动态替换符号引用具有动态性的特征,在类加载的解析阶段会替换一次,在程序执行过程中通过符号引用找到对应的类,但是发现类对象没有加载会加载对应的类并将符号引用替换成直接引用
通过JVM
参数-XX:+PrintStringTableStatistics
能配置打印字符串常量池中的统计信息
方法区的垃圾回收
JVM
规范没有对方法区的实现做强制约束,一些简单的实现可以不对方法区进行垃圾回收以及碎片进行压缩管理;实际上也存在未实现或未完整实现方法区类型卸载的垃圾收集器比如JDK11
的ZGC
就不支持类卸载,但是HotSpot
一直还是支持方法区的垃圾回收的
这种不支持方法区类卸载的垃圾回收器性能也会更高一些
方法区中类型的卸载条件非常苛刻,导致方法区的垃圾回收效果一般不咋明显;但是有时候方法区的垃圾回收又确实比较必要;以前Sun
公司的Bug
列表中很多严重的Bug
就是由于低版本的HotSpot
虚拟机未完全回收方法区导致的内存泄漏,就是不回收方法区还不行,但是回收大部分时间都做的无用工
大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP
和OSGI
这类频繁自定义类加载器的场景通常还确实需要JVM
具备类型卸载能力来保证不会对方法区造成过大的内存压力
方法区主要回收的是常量池中废弃的常量和不再使用的类,运行时常量池主要存放字面量和符号引用两大类常量;字面量主要包含文本字符串,被final
修饰的常量值等;符号引用属于编译原理方面的概念,包含类和接口的全限定类名、字段的名称和描述符、方法的名称和描述符
常量池的回收策略比较明确,主要常量没有被任何地方引用就可以被回收
类的回收比较麻烦,判断一个类不再使用的条件很苛刻,需要同时满足以下三个条件才允许当前类被回收,但是也不是像对象一样没有引用了就必然被回收,
该类的所有实例都已经被回收即堆中不存在该类及任何派生子类的实例,只要有一个实例存在,实例就会有一个指针指向当前类在方法区的类型信息
加载该类的加载器已经被回收,类对象记录了当前类是被哪个类加载器加载的,类加载也记录了加载过具体哪些类,这个条件除非是精心设计的可替换类加载器场景比如OSGI
和JSP
的重加载,否则一般是很难达成的,注意类加载器加载的所有类都卸载的情况下类加载器也会销毁
当前类的class
对象没有在任何地方被引用过,即没有在任何地方使用反射来访问该类
为什么要使用PC寄存器存储字节码指令地址呢?
因为CPU不停切换执行各个线程来实现并行并发效果,当CPU切换回当前线程时需要明确从哪一条指令继续向下执行,JVM
字节码解释器通过改变PC寄存器的值来明确将被执行的下一条字节码指令
PC寄存器为什么被设定为线程私有
因为线程上下文切换需要记录当前线程正在执行的字节码指令地址,如果程序计数器被设定为共享的,当线程抢到CPU时间片后程序计数器中保存的线程状态信息会被其他线程覆盖导致当前线程的执行被其他线程干扰
举例栈溢出情况
虚拟机栈的容量如果是固定的随着方法调用不停压榨栈帧最后由于虚拟机栈容量不足抛出StackOverflowError
异常,通过JVM
参数-Xss
可以设置栈的大小;如果虚拟机栈设置了可自动扩容最后由于没有内存无法扩容会抛出OutOfMemoryError
异常
只调整栈大小不一定能保证不会出现栈溢出,默认情况下虚拟机栈的大小约1M,不管虚拟机栈多大,错误死循环的递归调用一定会导致栈溢出,虚拟机栈不涉及垃圾回收问题
虚拟机栈也不是越大越好,大了也可能只是延缓发生栈溢出的时间或者频率;而且太大都是无效空间反而会挤占其他内存空间
方法中定义的局部变量是否都是线程安全的
StringBuffer
中的所有方法都添加了synchronized
同步锁,StringBuilder
没有添加同步锁,局部变量
引用数据类型也可能被多个线程访问因此局部变量不一定是线程安全的,要具体情况具体分析,始终保证单个线程对实例的原子性操作就是线程安全的
简述一下永久代
永久代是否会发生垃圾回收
为什么使用元空间替换永久代
Eden
和Survivor
比例如何分配
JVM
规范中指出默认情况下伊甸园区与单个幸存者区的比例是8:1
,但实际上不是8:1:1
,我的显示是6:1
;关于这点网上有些帖子说还可能受JVM
的自适应机制影响,通过JVM
参数-XX:-UseAdaptivePolicy
可以关闭自适应机制[开启内存分配自适应还可能导致两个幸存者区不一样大],-Use
的-
表示不使用,如果替换成+
表示使用;但是实际配置关闭自适应机制以后没有实现对应效果还是原样;要想完全自定义伊甸园区和幸存者区的比例需要使用JVM
参数-XX:SurvivorRatio=8
来自定义设置伊甸园区是单个幸存者区容量的8倍
HotSpot
为什么要分为新生代和老年代
JVM
内存模型,重点重排序, 内存屏障, happen-before, 主内存, 工作内存,直接内存
直接内存:本地内存就是直接内存,JDK8
以后的元空间就用的本地内存;直接内存是在Java
堆外直接向系统申请的内存区间;直接内存来源于NIO
,通过基于直接内存的DirectByteBuffer
实现操作系统和JVM
都能直接访问到一块内存地址减少一次用户态到内核态的转换,减少一次数据拷贝;NIO
在JDK1.4
被引入,在JDK1.7
引入NIO2
即AIO
,同时还引入了Files
和Path
等对NIO
增强的API
,JVM
没有权限释放直接内存,底层是依靠Unsafe
的方法通过操作系统来释放的;访问物理内存上的文件使用直接内存比堆的速度更快,JVM
进程代表的应用程序从JVM
内存中读取数据,JVM
内存中的数据需要从内核地址空间拷贝,内核地址空间的数据需要从物理磁盘上读取;通过JVM
进程向磁盘写入数据时也需要先将数据写入到用户态即用户地址空间,然后将数据复制到内核态即本地物理空间的内存上,再由操作系统将数据写出到物理磁盘上;相较于JVM
和操作系统都能访问的直接内存多了一次数据从用户态向内核态的拷贝,性能更低;直接内存的开辟回收成本高,直接内存不受JVM
管理,监控管理起来有难度,dump
文件也不会有相关记录;直接内存读写性能高,在频繁读写的场合下会更多的考虑使用直接内存;Java
中使用直接内存比较多的就是元空间和NIO
直接内存大小可以通过JVM
参数MaxDirectMemorySize
设置,如果不指定,默认与堆的最大值-Xmx
的参数值一致;注意这里的直接内存大小不包括元空间,是JVM
能通过比如NIO
访问的直接内存大小,老师后来提到元数据区、直接内存是本地内存中互斥的两个部分,JVM
进程占用的内存为堆内存加上元空间加上直接内存的总和
类加载与类卸载过程
概念:
类加器子系统负责从文件系统或者网络中将一个或多个字节码文件以二进制流的方式加载到内存结构中初始化成一个或多个Class
实例[元数据模板,通过元数据模板的构造器就能在堆空间中创建单个或多个对应类对象,通过Class
对象的getClassLoader()
方法可以获取负责该过程的类加载器对象,通过对象的getClass()
方法能获取到对应的Class
对象],除了该Class
实例外,方法区中还会存放运行时常量池信息[需要用的常量池加载到内存中就称为运行时常量池],字符串字面值和数字常量
字节码文件在文件头有一个特定的模数标识cafebaby
,该模数会参与链接阶段的验证
类加载器只负责字节码文件的加载,字节码文件是否可以被执行是由执行引擎决定的
类加载包含加载、链接和初始化三个环节
类加载过程
当前类HelloLoader
是否装载,已装载直接进入链接流程
没有装载使用类加载器进行装载[自定义类使用应用类加载器装载],如果字节码文件不是一个合法的字节码文件,类加载器加载的过程中会抛出异常,加载成功再内存中生成元数据模板即对应Class
实例
有了Class
对象执行链接步骤
初始化对象实例
加载环节
加载过程:
通过一个类的全限定类名从物理磁盘或者网络获取该类的二进制字节流
将字节流代表的静态存储结构转化为方法区的运行时数据结构
在内存中生成一个代表该类的java.lang.Class
对象,该对象作为方法区中该类的各种数据访问入口
被加载的字节码文件来源
本地文件系统
从网络中获取,典型应用就是Web Applet
从zip
压缩包中读取,这也是jar
、war
压缩格式的读取基础[jar
包war
包解压后都是字节码文件]
运行时通过计算生成,典型应用就是动态代理技术java.lang.reflect.Proxy
由其他文件生成,典型应用就是JSP
应用
从专有数据库中提取[比较少见]
从加密文件中解密获取,是一种防止字节码文件被反编译的保护措施[比如将.apk
格式替换成.zip
格式解压就能获取字节码文件,对字节码文件进行反编译就能盗版一个软件或者寻找软件漏洞,因此一般都会对字节码文件进行加密防止我们这种人反编译字节码,真正运行的时候会自动对加密后的字节码文件进行一个解密操作]
链接环节
链接过程
验证:通过验证文件格式、元数据验证、字节码验证、符号引用验证四种验证确保Class
文件的字节流信息符合当前虚拟机要求,保证被加载类正确且不会危害虚拟机自身安全,验证不通过报verify error
准备:在方法区中为所有类变量[类变量是被static
修饰的变量]分配内存,将所有非常量的类变量设置为默认初始值即零值,显示初始化的类变量在初始化环节设置指定值;但是注意被final
修饰的类变量即常量准备阶段会直接赋值指定值;准备阶段不会分配初始化实例变量,实例变量随对象创建一起被分配到堆区
解析:将常量池内的类、方法、接口、属性的符号引用转换为直接引用[符号引用理解为占位符,直接引用是目标对象的内存地址,这一步就是建立虚方法表的过程],解析一般会在初始化环节完成后再执行
初始化环节
初始化环节就是执行类构造器方法<clinit>()
的过程,这个<clinit>()
方法不同于类的构造器,是javac
编译器收集类中所有类变量的赋值动作和静态代码块中的语句合并自动得到对应字节码的<clinit>
方法[<clinit>
方法在字节码文件的Methods
下的<clinit>
下的Code
可以看到对应的赋值语句和静态代码快语句对应的字节码指令]
类在第一次执行实例化对象时才会执行初始化环节,暂时不清楚整个类加载过程是否在第一次实例化对象时才会执行,因此一个类的静态代码块只有在第一次实例化该类对应对象时才会被执行
如果一个类没有类变量或者静态代码块,编译生成的字节码文件中不会有<clinit>()
方法
如果一个类有父类,JVM
会保证子类<clinit>()
执行前父类的<clinit>()
方法已经执行
<clinit>
方法中的代码不管是定义中的赋值语句还是静态代码块中的语句都是自上而下按顺序进行显式赋值,这些类变量在链接环节已经分配了内存,因此即使在源文件中静态代码块中的变量赋值语句在变量定义前面也能正常执行,只是静态代码块会先赋值,然后被变量定义处的赋值语句再次覆盖
非法前向引用:在静态代码块后面定义的类变量只能在静态代码块中赋值,但是不能对在静态代码块中对该变量进行调用比如System.out.println(变量)
,如果在静态代码块前面定义的变量则不存在该问题
类的构造器对应的是<init>()
方法[<init>
方法在字节码文件的Methods
下的<init>
下的Code
可以看到构造方法对应的字节码指令]
JDK8
使用元空间即直接内存缓存已经加载完毕的Class
对象,虚拟机类加载时始终只会执行一次<clinit>()
方法保证类只被加载一次,虚拟机会给一个类的<clinit>()
方法在多线程下加锁[类在第一次被实例化时初始化],一个类在初始化的时候其他也在实例化该类的对象的线程都得等待<clinit>()
方法执行完毕,如果我们在静态代码块中写的代码如果导致<clinit>()
方法无法结束,会导致其他等待实例化该类对象的线程全部无限时阻塞等待且没有任何提示信息
类加载初始化环节的执行条件[对类主动使用会执行初始化环节,被动使用不会执行初始化环节]
对类主动使用的七种情况[除了这七种情况其他都是被动使用]
创建类实例
访问某个类或者接口的类变量或者对该类变量赋值
调用类的静态方法
通过反射Class.forName("全限定类名")
主动加载类对象
初始化一个类的子类[加载一个类前会首先保证加载该类的所有父类]
JVM
启动时被标明为启动类的类
JDK7
开始提供的动态语言支持
类加载器概念,类加载器举例
类加载器简述:类加载器时JVM
执行类加载机制的前提,类加载器负责通过各种方式将类的二进制数据流读入JVM
内部并将其转换为一个与目标类对应的Class
对象实例交给JVM
进行链接和初始化操作,一个类是否可以运行不归类加载器管而由执行引擎决定,JVM
支持引导型类加载器和自定义类加载器,JVM
规范将所有直接或间接继承于抽象类ClassLoader
的累加载器都划分为自定义类加载器,即JDK
提供的扩展类加载器ExtClassLoader
和系统类加载器AppClassLoader
都间接继承自ClassLoader
属于自定义类加载器
类加载器在JDK1.0
就出现了,那时只是单纯为了满足Java Applet
即Java
小程序研发出来的,如今类加载器在OSGI
即热部署和热代码替换、字节码加解密领域大放异彩,类加载器在最初设计的时候就没有被绑定在JVM
内部,不仅提供现成的类加载器实例,还允许用户自定义类加载并在系统中进行使用
引导类加载器、扩展类加载器、系统类加载器、用户自定义类加载器在逻辑上依次构成了上下级关系,通过ClassLoader.getSystemClassLoader()
可以获取Launcher$AppClassLoader
,通过appClassLoader.getParent()
可以获取extClassLoader
,通过extClassLoader.getParent()
尝试获取bootstrapClassLoader
会返回null
,因为设定上就不让用户获取到引导类加载器
Bootstrap Class Loader
是用C++
实现的,ExtClassLoader
和AppClassLoader
都是用Java
实现的
所有的类都是由类加载器加载的,用户自定义的类默认是通过单例appClassLoader
来加载,Java
的核心类库都是使用引导类加载器加载
方法区的类信息会记录当前类是被哪个类加载器加载的,类加载器也会记录加载过哪些类,一旦类加载的加载过的类对象都被销毁了,相应的类加载器也会销毁
JVM
将字节码文件加载到内存的加载方式分类[实际开发中一般都是两种方式混用]
显示加载
概念:在Java
代码中通过显示调用类加载方法如Class.forName(name);
、class.getClassLoader().loadClass(name);
或者ClassLoader.getSystemClassLoader().loadClass(name);
使用类加载器ClassLoader
加载字节码文件
隐式加载
概念:不通过显示加载方式加载的字节码文件都是隐式加载,通过JVM
自动控制加载过程,比如一个类引用了另一个类的对象,额外引用的类如果还没有被加载就会通过JVM
自动加载到内存中
系统需要支持类的动态加载或者需要对编译后的字节码文件进行加密解密操作时需要和类加载器打交道,开发者也可能需要自定义类加载器来重新定义类的加载规则来实现一些自定义的处理逻辑
比如自定义类加载器就可以不遵循双亲委派模型来避免双亲委派机制的劣势
类加载器的基本特征
双亲委派模型是JDK1.2
加入的类加载器机制,一开始类加载器并不遵守该机制。此外也不是所有的类加载器都遵守该模型,启动类加载器可能也会加载用户代码,JDK
提供的标准API
即使提供了默认的参考实现,但是仍然有可能需要用户来提供自己的实现,比如Java
中的JNDI
、JDBC
、文件系统、Cipher
等,都是利用JDK
内部的ServiceProvider/ServiceLoader
机制来实现的,这些情况不会使用双亲委派模型来加载而是使用上下文加载器加载
可见性:子类加载器可以访问父类加载器加载的类,但是父类加载器不能访问子类加载器加载的类,基于这个可见性我们可以使用类加载器去实现容器的逻辑
单一性:父类加载器的加载类的结果对子类加载器是可见的,只要父类加载器成功加载过一个类,子类就不会进行重复加载;但是像自定义类加载器这种可以创建多个类加载器实例,多个实例之间因为看不见对方的加载结果,同一个类仍然可能被同一种加载器类型的不同实例加载多次
类的唯一性
同一个虚拟机上使用同一个类加载器加载同一个字节码文件产生的类是唯一的,但是只要加载同一个字节码文件的类加载器不同,产生的类就不是相等的类,而保证一个字节码文件只会被同一个类加载器加载的机制是双亲委派机制。
每个类加载器都有一个独立的类名称命名空间
命名空间有该类加载器加载的类和其所有父类加载器加载的类组成
同一个命名空间中不会出现全类名相同的两个类;不同命名空间中完全可能出现全类名相同的两个类,在大型应用中也会借助该特性来运行同一个类的不同版本,比如在Tomcat
中让同一个工程通过不同类加载器的加载实现工程的隔离,在实际生产中应用比较广泛
用户自定义的类加载器可以通过UserClassLoader loader = new UserClassLoader("D;\\code\workspace_idea5\\JVMDemo\\src\\");
指定类加载器的要加载的字节码文件存放目录[不同的类加载器的加载目录是不一样的,系统类加载器的默认加载目录是out\\工程目录\\模块目录
下],并通过Class clazz = loader.findClass("com.earl.mall.Product");
加载指定全类名的字节码文件生成对应的class
实例
用户自定义的类加载器如果没有设计为单例模式可以创建多个实例,那么每个实例都是一个全新的类加载器,此时使用不同的类加载器实例加载同一个字节码文件生成的class
对象是不同的,此时同一个类就被加载了多次而且这些class
对象都是不同的,这些class
实例指向的方法区的Product
类模板结构也是不同的,这些class
实例通过getClassLoader()
获取的类加载器都是UserClassLoader
的不同实例,都不是同一个类加载器;在Tomcat
中就能借助这个点实现应用的隔离
如果运行程序时,程序中使用指定类加载器加载某个类,但是字节码文件又不在该类加载器的加载目录下会抛ClassNotFoundException
异常
类加载器分类
JVM
规范将类加载器分为引导类加载器和自定义类加载器,即使是HotSpot
自带的扩展类加载器和应用类加载器也和开发者自定义的类加载器一样被统称为自定义类加载器,凡是使用Java
语言实现派生于抽象类ClassLoader
的类加载器都被划分为自定义类加载器
引导类加载器[启动类加载器]是扩展类加载器的逻辑父加载器,扩展类加载器是应用类加载器[系统类加载器]的逻辑父加载器,应用类加载器是所有用户自定义类加载器的逻辑父加载器,每种父类加载器都可以通过子类加载器的getParent()
获取,只是引导类加载器无法被获取,调用该方法时会返回null
,这个父只是指逻辑上的父子关系,和Java
中的继承关系无关,只是一种包含关系指在子类加载器中包含着父加载器的字段引用
实际设计是在ClassLoader
中定义了parent
字段,类加载器的构造器传参父类加载器并为字段赋值,后续继承ClassLoader
都通过super(parent)
调用ClassLoader
中的单参构造方法
引导类加载器
引导类加载器使用C
和C++
实现嵌套在JVM
的内部,负责加载Java
的核心类库[JAVA_HOME/jre/lib/rt.jar|resources.jar
或者sun.boot.class.path
都是核心类库],用于加载JVM
自身需要的类,引导类加载器只加载包名为java
、javax
、sun
打头的类
引导类加载器加载扩展和应用类加载器,并将引导类加载器指定为二者的父类加载器
引导类加载器没有继承自java.lang.ClassLoader
,也没有父类加载器
通过URL[] urls = sun.misc.Launcher.getbootstrapClassPath().getURLS();
能获取引导类加载器负责加载的所有类的目录列表,通过url.toExternalForm()
可以获取引导类加载器加载目录的绝对路径
引导类加载器用户无法获取,任何获取引导类加载器的尝试最后都会获得null
扩展类加载器
扩展类加载器ExtClassLoader
是Launcher
类的内部类,间接继承自抽象父类ClassLoader
,负责加载JAVA_HOME/jre/lib/ext
子目录即扩展目录下的类库,如果用户创建的jar
包放在该扩展目录下,jar
包中的类也会自动由扩展类加载器加载,父类加载器为启动类加载器
通过String extDirs = System.getProperty("java.ext.dirs");
能够获取到扩展类加载器加载目录列表以分号分隔的拼接字符串,一般就两个目录JAVA_HOME/jre/lib/ext
和C:\WINDOWS\Sun\Java\lib\ext
应用/系统类加载器
应用类加载器AppClassLoader
是Launcher
类[Lancher
类是JVM
的入口应用]的内部类,间接继承自抽象父类ClassLoader
[AppClassLoader
和ExtClassLoader
一样直接继承自URLClassLoader
],负责加载环境变量classpath
即用户自定义类或者系统属性java.class.path
指定路径下的类库,是用户自定义类的默认类加载器,是用户自定义类加载器的默认父加载器,即使用户自定义类直接继承ClassLoader
,其父类加载器还是系统类加载器;系统类加载器是使用频率最高的类加载器
在Launcher
类的构造器中先调用ExtClassLoader.getExtClassLoader()
创建扩展类加载器,然后又调用AppClassLoader.getAppClassLoader()
传参扩展类加载器将扩展类加载器作为AppClassLoader
的父加载器创建系统类加载器,给具体的parent
字段赋值是通过super(parent)
调用顶级父类ClassLoader
的构造器实现的,创建完系统类加载器以后会通过Thread.currentThread().setContextClassLoader(this.loader);
将系统类加载器设置为默认的线程上下文类加载器
扩展类加载器在调用构造器时传递的第二个参数parent
值是null
,用来表示引导类加载器
ClassLoader.getSystemClassLoader()
获取的就是系统类加载器
用户自定义类加载器
自定义类加载器应用场景:
隔离加载类[不同框架的类、框架和用户的类的全限定类名可能相同,大型主流框架一般都会自定义类加载器将框架和用户代码加载到不同环境中避免全限定类名相同的类发生冲突,类仲裁如果发现两个类的全类名相同会出现类冲突的问题,不解决会抛出异常]
tomcat
这类Web
应用服务器,内部也自定义了好几种类加载器,用于隔离同一个Web
应用服务器上的不同应用程序
利用类加载器的命名空间提供类似容器、模块化的功能实现类似进程内隔离的效果,比如,两个依赖于同一个类库的不同版本的模块,如果分别被不同的类加载器或者容器加载,就可以互不干扰,在类的隔离方面集大成者有JavaEE
、OSGI
和JPMS
等
修改类的加载方式[除了BootstrapLoader
其他类加载器不是必须使用,用户可以根据自己需求自定义类加载器来替代扩展类加载器和应用类加载器]
扩展加载源[除了上述字节码获取方式用户还想从数据库网络机顶盒等其他方式中获取字节码二进制信息流]
任意能获取字节流的方式都能扩展系统从各种数据源获取字节码二进制数据流的能力,而不是只能从本地文件系统获取字节码信息
防止源码泄露[java
代码很容易被编译和篡改,可以通过自定义类加载器在加载过程中自动对字节码文件进行解密然后生成真正的字节码二进制信息流进行类加载防止源码泄漏]
自定义类加载器的步骤
继承ClassLoader
,JDK1.2
前继承ClassLoader
需要重写loadClass()
方法实现自定义类加载过程,JDK1.2
以后不建议用户覆盖loadClass()
方法,建议把自定义的类加载逻辑重写在findClass()
方法中[将字节码文件以二进制流的形式写入并进行处理,如果是加密后的字节码文件需要先对字节流数据进行解密]
如果自定义类加载器没有太复杂的需求可以直接继承URLClassLoader
类,避免用户去自己重写findClass()
,自定义类加载器一般都要直接或者间接继承自ClassLoader
Java
开发者通过自定义类加载器实现类库从本地或者网络动态加载是Java
语言繁荣的关键因素之一,比如OSGI
组件框架和Eclipse
的插件机制都是通过类加载器实现的插件机制,能够实现为应用程序提供动态增加新功能,实现热部署无需重新打包发布应用程序
自定义类加载器还可以实现应用的隔离,像tomcat
、spring
等中间件组件框架都在内部实现自定义类加载器隔离不同的组件模块,这方面要比C/C++
好太多了,要想不修改C/C++
程序就能为应用添加新功能几乎是不可能实现的
自定义类加载器的实现方式
1️⃣自定义类加载器继承自ClassLoader
重写loadClass()
方法指定类加载的完整逻辑,通过重写该方法可以避免自定义类加载器使用双亲委派机制
2️⃣自定义类加载器继承自ClassLoader
重写findClass()
方法指定字节码文件的加载以及转换成二进制流的逻辑,该方法被loadClass()
方法调用,可以保证自定义类加载器仍然遵守双亲委派机制
自定义类加载器可以通过重写loadClass()
方法抹去双亲委派机制,此时是否能用自定义的类加载器加载核心API
呢?还是不行,因为JDK
还为核心类库提供了一层保护机制,不管是自定义类加载器还是JDK
提供的类加载器,最后都要调用JDK
提供的本地方法defineClass()
,该方法会调用preDefineClass()
提供对JDK
核心类库的保护
自定义类加载器的父类加载器都是系统类加载器
代码实现
注意事项
ClassLoader
中的loadClass
方法调用findClass
方法,loadClass
中主要实现了双亲委派机制,findClass
中定义了字节码二进制信息流的查找加载方式,并调用defineClass
本地方法传参字节码二进制流获取class
对象
所有的自定义类加载器都应该继承ClassLoader
,开发者可以根据需要选择重写loadClass
方法或者defineClass
方法,JVM
建议重写findClass
不要重写loadClass
避免破坏双亲委派模型破坏原有结构造成系统容易出现问题,开发者定义好自定义类加载器后只需要调用对应的loadClass
方法就行,既能保证双亲委派模型,也能保证自定义字节码二进制信息流的自定义加载方式
所有类的加载包括JDK
核心类库的加载都使用的ClassLoader
的loadClass
方法
实现步骤
创建一个自定义类加载器继承自ClassLoader
声明一个字段指定类加载器的类加载目录
重写findClass(String name)
方法定义根据传入的全类名找到字节码文件并加载字节码二进制信息流到JVM
中,调用本地方法defineClass
传入字节码二进制数组获取class
对象并返回
[示例代码]
xxxxxxxxxx
package io.renren.classloader;
import java.io.BufferedInputStream;
import java.io.ByteArrayOutputStream;
import java.io.FileInputStream;
import java.io.IOException;
public class CusClassLoader extends ClassLoader {
private String loadPath;
public CusClassLoader(String loadPath) {
this.loadPath = loadPath;
}
public CusClassLoader(ClassLoader parent, String loadPath) {
super(parent);
this.loadPath = loadPath;
}
public static void main(String[] args) {
CusClassLoader loader = new CusClassLoader("d:/");
try {
Class<?> clazz = loader.loadClass("com.earl.mall.Product");
System.out.println("加载此类的类加载器为: " + clazz.getClassLoader().getClass().getName());//加载此类的类加载器为: io.renren.classloader.CusClassLoader
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
protected Class<?> findClass(String name) throws ClassNotFoundException {
BufferedInputStream bis = null;
ByteArrayOutputStream baos = null;
name = name.replaceAll("\\.", "/");
try {
String byteCodeFilePath = loadPath.concat(name).concat(".class");
bis = new BufferedInputStream(new FileInputStream(byteCodeFilePath));
//准备字节数组输出流ByteArrayOutputStream将输入流中的数据通过输出流写出到内存中输出流实例中的byte数组中
baos = new ByteArrayOutputStream();
int len;
byte[] buffer = new byte[1024];
while ((len = bis.read(buffer)) != -1) {
//将字节码字节数组写到ByteArrayOutputStream实例中的数据数组中
baos.write(buffer, 0, len);
}
//将字节码文件字节数据封装成byte数组
byte[] byteCode = baos.toByteArray();
return defineClass(null, byteCode, 0, byteCode.length);
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
if (baos != null)
baos.close();
} catch (IOException e) {
e.printStackTrace();
}
try {
if (bis != null)
bis.close();
} catch (IOException e) {
e.printStackTrace();
}
}
return null;
}
}
注意涉及到两个类型做类型转换时,只有两个类型都被同一个类加载器加载才能进行类型转换,否则类型转换时会发生异常
JDK9
以后系统类加载器变成了ClassLoaders
的内部类了,系统类加载器的父类加载器变成了ClassLoaders$PlatformClassLoader
而不再是扩展类加载器了,平台类加载器的父类加载器是引导类加载器,因为JDK9
新特性模块化系统导致类加载器也有了一些新变化
扩展机制被移除,扩展类加载器被重新命名为平台类加载器,可以通过ClassLoader.getPlatformClassLoader()
来获取
JDK9
是基于模块化构建的,将原来的tr.jar
、tools.jar
拆分成数十个JMOD
文件,Java
类库天然满足了可扩展的需求,无需再保留JAVA_HOME\lib\ext
目录,此前使用JAVA_HOME\lib\ext
目录或者java.ext.dirs
系统变量来扩展JDK
的功能已经没有继续存在的价值了
平台了加载器和系统类加载器都不再继承自java.net.URLClassLoader
,启动类加载器BootClassLoader
、平台类加载器、系统类加载器全部继承自jdk.internal.loader.BuiltinClassLoader
,BuiltinClassLoader
继承自SecureClassLoader
,JDK9
以后得版本自定义类加载器就不能继承自URLClassLoader
了;引导类加载器也变成了JVM
和Java
类库共同协作实现的类加载器,但是为了向下兼容,尝试获取启动类加载器仍然会返回null
类加载器有了名称,新增了getName()
方法来获取类加载器的名称,该方法主要用于与类加载器的调试相关场景下
双亲委派机制也发生了一些变化,Java
代码被划分成了若干模块,不同的模块由不同的类加载器进行加载,因此在进行类加载在把当前类委派给父类加载器加载前先判断当前类是否能归属到某一个系统模块中,如果是就找哪一个类加载器负责加载该模块并直接将当前类交给对应类加载器进行加载
获取类加载器的几种方式
通过类对象class.getClassLoader()
获取指定类对象类加载的类加载器
数组比如String[]
的类型是[Llang.java.String;
,在JVM
中没有数组这种类型,实际上还是JVM
加载String
这种类型然后动态地创建几个内存连续的相同类型元素,但是数组对象arr
仍然能调用arr.getClass().getClassLoader();
,只是此时的结果是null
,数组类的类加载器和数组中元素类型的类加载器是一样的,比如String
类型元素数组的类加载器是引导类加载器,如果数据的元素类型是基本数据类型[基本数据类型是虚拟机预设的不需要类加载器加载],数组类是没有类加载器的[这种情况下也是返回null
]
通过当前线程上下文Thread.currentThread().getContextClassLoader()
获取当前代码所在类的负责加载的类加载器,默认情况下上下文类加载器就是系统类加载器
通过ClassLoader.getSystemClassLoader().getParent()
可以依次获取到应用类加载器和扩展类加载器
类加载器的继承结构
ClassLoader
是一个没有抽象方法的抽象类,其中定义了loadClass(String)
、resolveClass(Class<?>)
、findClass(String)
、defineClass(byte[],int,int)
比较重要的几个方法,设置成抽象类只是让其不能实例化,但是其中有方法体为空的方法比如findClass(String)
功能是通过类的全类名去加载字节码二进制流
public final ClassLoader getParent()
:获取当前类加载器的父类加载器
public Class<?> loadClass(String name) throws ClassNotFoundException
:基于双亲委派机制加载全类名为name
的字节码文件或者网络传输的二进制流数据,返回对应类的Class
实例,如果找不到指定类则抛ClassNotFoundException
,该方法的实现就是双亲委派机制的实现
该方法就是ClassLoader.getSystemLoader().loadClass("com.earl.mall.Product")
时的逻辑
xxxxxxxxxx
classLoader.loadClass(name)
public Class<?> loadClass(String name) throws ClassNotFoundException {
return loadClass(name, false);1️⃣
}
1️⃣classLoader.loadClass(name, false)
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{//入参resolve为true表示加载类的同时进行解析环节,该参数为false表示不需要解析
synchronized (getClassLoadingLock(name)) {//为加载类操作上锁,保证类只会被加载一次
Class<?> c = findLoadedClass(name);//首先去缓存即类加载器的命名空间中检查是否已经加载过同名类,如果已经存在直接返回对应`class`对象无需加载,否则返回null
if (c == null) {//没有被加载过进入if语句块进行加载
long t0 = System.nanoTime();//获取系统时间
try {
//如果当前类加载器不是引导类加载器就递归调用父类加载器的loadClass(name, false)方法让父类加载器来进行类加载,这里的递归会先找到引导类加载器,引导类加载器加载不了才会在后续返回每次递归调用的后序遍历时依次从上
if (parent != null) {
c = parent.loadClass(name, false);
} else {
//如果扩展类加载器的父类加载器是引导类加载器就检查引导类加载器是否能够加载当前类
c = findBootstrapClassOrNull(name);//如果引导类加载器不能加载会返回null
}
} catch (ClassNotFoundException e) {
}
if (c == null) {
//如果当前类加载器的父类加载器不能加载当前类调用findClass(name)判断当前类加载器是否能加载当前类,如果还是不能加载就返回上一次子类加载器调用c = parent.loadClass(name, false);处返回null表示当前父类无法加载当前类,子类加载器在重复这个过程判断自身是否能加载当前类,说白了就是递归的后序遍历判断当前类加载器是否能加载当前类,这就是双亲委派机制的实现方式
long t1 = System.nanoTime();
c = findClass(name);
// thi is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
protected Class<?> findClass(String name) throws ClassNotFoundException
:ClassLoader
的findClass(name)
方法没有方法体,如果不重写就进行调用会抛出ClassNotFoundException(name)
异常,该方法在子类URLClassLoader
中做了重写,在扩展类加载器和系统类加载器中都没有重写该方法,即扩展类加载器和系统类加载器都使用URLClassLoader
重写的findClass(name)
逻辑;只能在同一个包、子包或者子类中调用
功能:该方法会在检查完父类加载器是否能加载当前类后被loadClass()
方法调用检查当前类加载器能否加载当前类,实际上loadClass()
主要实现双亲委派机制,findClass()
才是判断当前类是否能被当前类加载器加载并进行加载的方法
在JDK1.2
以前还没有双亲委派机制自定义类加载器总是会重写loadClass
方法实现自定义的类加载逻辑,JDK1.2
以后已经不再建议开发者去覆盖loadClass()
方法而是将类加载的逻辑写在findClass()
方法中来保证自定义的类加载器也符合双亲委派机制
findClass()
方法通常和defineClass()
方法一起使用,自定义类加载器通过重写findClass()
方法自定义类加载规则,取得加载器的字节码二进制流后调用defineClass()
方法生成对应的类的class
实例;一般自定义类加载器都是重写findClass()
方法来编写字节码二进制流的加载规则,然后通过调用defineClass()
方法解析二进制码生成class
对象
[uRLClassLoader.findClass(name)
]
xxxxxxxxxx
protected Class<?> findClass(final String name) throws ClassNotFoundException
{
final Class<?> result;
try {
result = AccessController.doPrivileged(
new PrivilegedExceptionAction<Class<?>>() {
public Class<?> run() throws ClassNotFoundException {
String path = name.replace('.', '/').concat(".class");//拼出字节码文件的相对路径
Resource res = ucp.getResource(path, false);//通过字节码文件的相对路径获取字节码数据
if (res != null) {
try {
return defineClass(name, res);//通过字节码二进制数据创建class实例并直接返回
} catch (IOException e) {
throw new ClassNotFoundException(name, e);
}
} else {
return null;
}
}
}, acc);
} catch (java.security.PrivilegedActionException pae) {
throw (ClassNotFoundException) pae.getException();
}
if (result == null) {
throw new ClassNotFoundException(name);
}
return result;
}
[uRLClassLoader.defineClass(name, res)
]
xxxxxxxxxx
private Class<?> defineClass(String name, Resource res) throws IOException {
long t0 = System.nanoTime();
int i = name.lastIndexOf('.');
URL url = res.getCodeSourceURL();
if (i != -1) {
String pkgname = name.substring(0, i);
// Check if package already loaded.
Manifest man = res.getManifest();
definePackageInternal(pkgname, man, url);
}
// Now read the class bytes and define the class
java.nio.ByteBuffer bb = res.getByteBuffer();
if (bb != null) {
// Use (direct) ByteBuffer:
CodeSigner[] signers = res.getCodeSigners();
CodeSource cs = new CodeSource(url, signers);
sun.misc.PerfCounter.getReadClassBytesTime().addElapsedTimeFrom(t0);
return defineClass(name, bb, cs);
} else {
byte[] b = res.getBytes();
// must read certificates AFTER reading bytes.
CodeSigner[] signers = res.getCodeSigners();
CodeSource cs = new CodeSource(url, signers);
sun.misc.PerfCounter.getReadClassBytesTime().addElapsedTimeFrom(t0);
return defineClass(name, b, 0, b.length, cs);
}
}
protected final Class<?> defineClass(String name, byte[] b, int off, int len) throws ClassFormatError
功能:传入一个字节码二进制流解析得到JVM
能识别的Class
对象,off
是从第几个字节开始读,len
是读取字节的长度,通过该方法可以把字节码文件实例化为class
对象,我们也可以从网络中获取字节码二进制流使用该方法创建class
对象
xxxxxxxxxx
protected final Class<?> defineClass(String name, byte[] b, int off, int len)
throws ClassFormatError
{
return defineClass(name, b, off, len, null);
}
protected final Class<?> defineClass(String name, byte[] b, int off, int len,
ProtectionDomain protectionDomain)
throws ClassFormatError
{
protectionDomain = preDefineClass(name, protectionDomain);
String source = defineClassSourceLocation(protectionDomain);
Class<?> c = defineClass1(name, b, off, len, protectionDomain, source);
postDefineClass(c, protectionDomain);
return c;
}
private native Class<?> defineClass1(String name, byte[] b, int off, int len,
ProtectionDomain pd, String source);
protected final void resolveClass(Class<?> clazz)
该方法可以手动在Class
对象创建完成时执行解析操作
protected final Class<?> findLoadedClass(String name)
查找并返回名称为name
已经被加载过的class
实例,如果没找到返回null
SecureClassLoader
继承自ClassLoader
,URLClassLoader
继承自SecureClasssLoader
URLClassLoader
重写了ClassLoader
中的findClass(name)
、findResource()
等方法,在扩展类加载器和系统类加载器中都没有重写该方法,即扩展类加载器和系统类加载器都使用URLClassLoader
重写的findClass(name)
逻辑,此外还增加了URLClassPath
类协助获取字节码流等功能,开发者在自定义类加载器时如果没有太复杂的业务需要,可以直接继承自URLClassLoader
避免自己去编写findClass()
方法以及其他获取字节码流的方法,更便捷地自定义类加载器
SecureClassLoader
中增加了对代码源的位置和证书的验证以及对字节码的访问权限验证的方法,开发者一般只会和他的子类URLClassLoader
打交道
扩展类加载器和系统类加载器都继承自URIClassLoader
在系统类加载器中对ClassLoader
的loadClass(String,boolean)
方法进行了重写,这里面只是增加了一些判断的操作,判断之后最终仍然通过super.loadClass()
调用ClassLoader
实现的loadClass()
方法
这两个类加载器因为都调用的是ClassLoader
实现的loadClass()
方法,因此都遵循双亲委派机制
Class.forName()
和ClassLoader.loadClass
的区别
Class.forName()
是一个静态方法,传参类的全限定类名返回一个Class
对象,加载类的同时会进行类的初始化阶段
ClassLoader.loadClass()
是一个实例方法,需要一个类加载器实例来调用,该方法只会加载一个类,但是不会执行类的初始化阶段并调用<clinit>()
方法,只有该类在第一次主动使用时才会进行初始化,而且也不会执行类的解析环节,但是可以指定类的加载器
简述类的生命周期
基本数据类型由虚拟机预先定义[不需要进行类加载],引用数据类型需要类[类是统称,除了类还可以指代接口、注解、枚举类]的加载
使用类的时候会先判断当前类是否已经被类加载过,如果还没有加载过就需要使用对应的类加载器进行加载,加载过程中会对字节码文件做验证操作,如果不是合法的字节码文件会抛出相关的异常;验证没问题就可以继续进行链接阶段的准备、解析对类变量进行默认赋值并将符号引用改成直接引用,然后在初始化阶段对类变量进行显示赋值;初始化完成类就被成功加载了,此时就会在方法区存放被加载类的模板,调用类的静态方法、创建类的实例或者调用类的成员变量都属于对类的使用环节,类使用完毕以后会对类进行卸载
有些类的生命周期和JVM
一样是无法被卸载的,类被回收需要方法区进行GC
,没有进行GC
即使类使用结束也不会立即被回收,
类的生命周期中的七个阶段
加载
职责:将字节码文件加载到机器内存中,并在内存中构建Java
类的原型--类模板对象,类模板对象就是Java
类在JVM
内存中的一个快照,保存着从字节码文件中解析出来的常量池、类字段、类方法等信息;JVM
在运行期能通过类模板获取Java
类中的任意信息,访问成员变量以及调用方法
反射机制就是基于JVM
内存中的类模板
类加载器只和加载阶段有关,加载阶段结束,方法区的类模板结构和堆中的Class
实例都会完成创建,类加载器和链接环节以及初始化环节没有关系,这两个环节由JVM
负责
具体任务
在src
目录开始根据类的全名获取类的二进制数据流
通过Class clazz = Class.forName("java.lang.String");
可以手动加载类并获取对应的Class
实例
解析二进制数据类为方法区的Java
类模板
在堆中创建当前类的java.lang.Class
实例作为方法区该类模板的各种数据的访问入口
Class
实例是instanceKlass
实例的一个镜像,是访问类型元数据的入口,也是实现反射的入口,通过class
实例能访问类模板中的各种数据,可以把class
对象狭隘地理解为指向类模板结构
Class
类的构造器是私有的,只有JVM
能够创建
通过Class
对象可以通过反射获取当前类声明的所有方法、获取每个方法的修饰符、返回值类型、方法名、参数列表
获取二进制流的方式
磁盘上的字节码文件
读入jar
、zip
等归档数据包提取类文件
存在数据库中类的二进制数据
使用网络协议通过网络进行传输加载
运行时动态生成类的二进制信息
🔎:如果输入的数据不是字节码文件的格式会抛出ClassFormatError
异常
数组类的加载
数组类本身不是由类加载器负责加载创建的,JVM
运行时根据元素类型和数组维度可以直接创建,数组的元素类型如果是引用数据类型此时才会使用到类加载器加载对应元素的类
链接
验证
职责:保证加载的字节码是符合JVM
规范的,验证包含格式检查、语义检查、字节码验证、符号引用验证四个环节
格式检查:包含魔数检查、版本检查、指令长度检查,格式检查会和加载阶段同时执行,格式检查成功后才会将类的二进制数据信息加载到方法区中;在方法区生成类模板以后才会进行后续三个验证环节
语义检查:检查字节码信息在语义上是否符合JVM
规范,例如除Object
外所有的类的属性表中都有指定父类、检查被final
修饰的方法或者类没有被重写或者继承过、非抽象类是否实现了所有抽象方法或者接口方法、检查是否存在不兼容的方法[方法不同同名的情况下形参列表还相同、方法不能同时被abstract
和final
修饰]
字节码验证:对字节码流进行分析,判断字节码是否可以被正确执行;比如检查字节码执行过程中是否会跳转到一条不存在的指令、方法的调用和变量的赋值已经指令的调用是否传递了正确类型的参数
栈映射帧[StackMapTable
]:用于检测在特定字节码处。局部变量表和操作数栈是否有正确的数据类型,该过程只能尽可能检查可以明显预知的问题,不能完全确定字节码可以被安全执行,没有通过该检查的类不会被虚拟机装载,通过了也不能证明这个类没有问题;StackMapTable
在方法表的Code
属性中的属性表的StackMapTable
中
符号引用验证:符号引用验证在解析环节才会执行,用于校验符号引用对应的类或者方法是否确实存在,并且当前类有访问这些数据的权限,如果要使用的类在系统中找不到会抛出NoClassDefFoundError
,如果一个方法在系统中找不到会抛出NoSuchMethodError
准备
职责:为静态变量分配内存并初始化为默认值,为字面量声明的常量进行显示赋值
相当于为类变量分配内存并赋值默认值时常量直接赋值最终值,因为常量不能被重复赋值不能像静态变量一样在解析环节再进行显示赋值
常量如果是对象实例比如static final String str = new String("123")
在初始化阶段执行<clinit>()
方法时才会被显示赋值,对应字节码中字段表中的字段也不会有属性ConstantValue
,但是通过字面值声明的常量字段表中的对应字段有ConstantValue
属性
准备阶段不会执行任何初始化代码
老师的结论有点啰嗦:使用static
+final
修饰且显示赋值中不涉及到方法或者构造器调用的基本数据类型或String
类型的显示赋值在链接阶段的准备环节进行
解析
职责:将类、接口、字段和方法的符号引用转换为直接引用
类模板中有方法表,所有的方法都列在表中,需要调用一个类的方法时只要知道该方法在方法表中的偏移量就可以直接调用该方法,通过解析环节可以将符号引用转换为在对应类方法表中的位置,从而使得方法具备被实际调用的条件;要得到一个类、方法、字段在内存中的指针或者偏移量,对应类一定被加载完成;
在HotSpot
中加载、验证、准备和初始化都会按顺序执行,但是解析一般会在初始化完成后再执行
Java
中直接使用字符串常量是会在类的常量池中出现一个CONSTANT_String
结构表示一个字符串常量,该常量会引用一个字符串字面量CONSTANT_utf8_info
,JVM
运行时常量池中会维护一个字符串拘留表[就是串池,JDK7
开始移入了堆空间],会保留所有出现过的字符串常量并且保证没有重复项,以CONSTANT_String
结构出现过的字符串都会在串池中创建对应的字符串对象
初始化
职责:初始化类的静态变量为静态变量进行显式赋值,在初始化阶段才会真正开始执行类中定义的Java
程序代码[静态代码块、静态变量的显示赋值语句],初始化以前的步骤没问题说明类可以被顺利装载到系统中,初始化阶段最重要的工作是执行类的初始化<clinit>()
方法,该方法由前端编译器生成并由JVM
调用,是由常量的代码显示赋值语句、静态变量的赋值语句以及静态代码块合并产生
在加载一个类前虚拟机总会先加载其父类,因此父类的<clinit>()
总是先于子类的<clinit>()
之前被调用,对应的父类的静态方法总是先于子类的静态方法先执行
<clinit>()
方法会合并有显示赋值语句的静态变量、采用代码而非字面量显示赋值的常量、静态代码块的代码,对于非静态字段、没有显示赋值的静态字段以及没有显示赋值以及使用字面量赋值的常量都不会参与生成<clinit>()
方法
静态代码块是类的初始化代码块,接口中不能有静态代码块
<clinit>()
的线性安全性问题
JVM
会确保<clinit>()
方法调用时的线程安全性,保证<clinit>()
方法只被执行一次,<clinit>()
方法的访问标志只有一个static
,没有带synchronized
,<clinit>()
的锁是一个隐式的锁,如果<clinit>()
方法没有明确的终止时间,可能导致多个线程阻塞导致死锁,这种死锁是很难发现的,因为我们看不到相应的锁信息,而且这种死锁无法通过jvisualvm
看到
一个典型的场景是在A
的静态代码块中去加载B
,且在B
的静态代码块中去加载A
,如果两个类同时被两个线程加载会因为都在等对方加载完导致两个类的加载发生死锁,最终导致所有需要加载这两个类的线程全部阻塞等待,因此在一个类的静态代码块中加载另外一个类一定要特别小心
Java
程序对类的使用分为主动使用和被动使用两种
一个类被主动使用<clinit>()
方法才会被调用,被动使用不会调用<clinit>()
方法,即一个类被成功加载不一定会执行初始化阶段
JVM
规定类或者接口只会在首次使用时才会被装载,而且只有在主动使用的情况下加载类才会进行初始化阶段,被动使用的类通常会进行类加载但是不会经历初始化阶段
主动使用的情况
1️⃣使用new
关键字、或者通过反射、克隆、反序列化创建一个类的实例
2️⃣使用字节码指令invokestatic
调用类的静态方法
3️⃣使用字节码指令getstatic
、putstatic
访问或者赋值类或者接口的静态字段或者非字面量显示赋值的常量,注意使用字面量显示赋值的常量被访问不会触发类加载的初始化阶段
4️⃣使用java.lang.reflect
包中的反射类的方法时,比如Class.forName(str)
5️⃣初始化子类时发现父类还没有初始化触发父类的初始化
这个规则不适用于接口,初始化一个类时并不会先初始化他实现的接口,初始化一个接口时也不会先初始化他的父接口;接口只有在程序首次使用接口中的静态字段或者访问非字面量显示赋值的常量时才会进行初始化
6️⃣一个接口定义了被default
关键字修饰的默认方法,直接或者间接实现该接口的类初始化,在该类初始化以前会触发该接口初始化
7️⃣JVM
启动时会通过引导类加载器初始化main()
方法所在的类,主方法的执行将依次导致所需的类的加载、链接和初始化
8️⃣首次调用MethodHandle
实例时需要初始化该MethodHandle
指向的方法所在的类[即涉及解析REF_getStatic
、REF_putStatic
、REF_invokeStatic
方法句柄对应的类需要先进行初始化]
MethodHandle
:这是反射包下的一个类
被动使用的情况
访问一个静态字段时只有真正声明该字段的类才会被初始化,比如通过子类引用访问父类中声明的静态变量不会导致子类初始化,但是子类仍然会被加载
定义引用类型数组不会触发该类的初始化
比如Parent[] parents = new Parent[10]
,parents
的类型为[Lcom.earl.java.Parent
数组类型,数组类型的父类也是Object
,只是通过上述语句创建parents
数组不会涉及Parent
类的初始化,在调用Parents[0]=new Parent();
的时候才会触发Parent
类的初始化
使用在链接阶段的准备环节已经被字面值显示赋值的常量不会触发常量声明所在类或者接口的初始化
int CONSTANT=new Random().nextInt(10);
实际上是一个非字面量显式赋值的常量[因为接口中的变量默认修饰符是public static final
],在接口类加载时的初始化阶段通过<clinit>()
方法进行赋值
调用ClassLoader
类的loadClass()
方法比如Class clazz = ClassLoader.getSystemClassLoader().loadClass("com.earl.mall.Product");
加载一个类不会触发初始化阶段
注意Class.forName("com.earl.mall.Product")
加载一个类会触发初始化阶段
使用
职责:开发人员可以在程序中访问和调用类的静态字段和静态方法,或者使用new
关键字创建指定类的对象实例,创建对象实例以后可以通过对象去访问非静态字段和实例方法
卸载
职责:
一个Class
对象总会引用它的类加载器,通过class.getClassLoader()
方法可以获取到对应的类加载器,类加载器中也会维护一个集合来存放当前类加载器加载的类的Class
对象,这种关系称为双向关联关系
对象实例可以通过getClass()
方法获取对应类的class
对象,即class
对象会被类对应的所有实例引用
当一个Class
对象没有被任何对象实例引用时,Class
对象就会结束生命周期被回收,方法区的类模板数据也会被卸载从而结束类的生命周期,Class
对象被销毁,方法区中的类模板结构因为没有被引用才会回收
类的回收比较麻烦,判断一个类不再使用的条件很苛刻,需要同时满足以下三个条件才允许当前类被回收,但是也不是像对象一样没有引用了就必然被回收,一个已经被加载的类被卸载的概率很小且就算卸载卸载的时间也不能确定,因此开发者开发系统功能时不应该把特定类型的卸载作为功能的假设前提
该类的所有实例都已经被回收即堆中不存在该类及任何派生子类的实例,只要有一个实例存在,实例就会有一个指针指向Class
对象,Class
对象指向当前类在方法区的类型信息
加载该类的加载器已经被回收,Class
对象记录了当前类是被哪个类加载器加载的,类加载器中的集合也记录了加载过具体哪些类,这个条件除非是精心设计的可替换类加载器场景比如OSGI
和JSP
的重加载,否则一般是很难达成的,注意类加载器加载的所有类都卸载的情况下类加载器也会销毁
启动类加载器加载的类即Java
的核心API
在整个JVM
运行期间都不会被卸载,启动类加载器本身也不可能被卸载,JVM
规范和JLS
规范[Java
语言规范]都提到了这点
系统类加载器和扩展类加载器实例在JVM
运行期间也不会被卸载,总是能被直接或者间接地访问到,因此被系统类加载器和扩展类加载器加载的类型也不太可能在JVM
运行期间被卸载
开发者自定义的类加载器实例一般会采用缓存策略缓存起来提高系统性能,因此被用户自定义加载器加载的类型在JVM
运行期间也几乎不太可能被卸载,这些类型只有在很简单的上下文环境中还要借助强制调用垃圾回收功能才能被卸载,因为垃圾回收的时间不确定因此即便这些类被卸载,卸载的时间也是无法确定的
当前类的class
对象没有在任何地方被引用过,即没有在任何地方使用反射来访问该类,class
对象可能被直接引用,或者被当前类的实例引用或者被类加载器引用
Class
对象的引用全部断开后,如果再次需要使用对应类,会首先检查CLass
对象是否还存在,如果存在则直接使用Class
对象当前类不需要重新加载,如果Class
对象已经不存在,JVM
会重新加载当前类
🔎:其中验证、准备、解析三个环节统称为链接阶段,因此也可以说类的生命周期有五个阶段,加载-链接(验证-准备-解析)-初始化-使用-卸载
类文件结构
JVM
中两个class
对象是同一个类要求class
对象的全限定类名完全一致且两个class
对象的类加载器对象必须相同,
即使来自同个Class
文件,只要类加载器实例不同,两个class
对象就不相等;class
对象的类加载器引用会作为类的信息保存在方法区中
类加载器可以打破双亲委派吗,怎么打破
双亲委派机制
双亲委派机制原理
1️⃣:如果一个类加载器收到类加载请求,该类加载器会首先委托其父类加载器去执行该类加载请求
2️⃣:如果父类加载器还存在父类加载器,则继续委托其父类加载器执行该类加载请求;最终会委托给引导类加载器
3️⃣:此时如果当前类加载器可以完成类加载任务就由当前类加载并直接返回,如果当前类无法完成该加载任务就由当前类加载器的子类加载器去尝试完成类加载任务并返回
例子:小孩拿着苹果问"妈妈你吃吗?",妈妈拿着苹果问奶奶"妈妈你吃吗?",奶奶说我吃就奶奶吃,奶奶说我不吃妈妈说我吃就妈妈吃,妈妈说我不吃就小孩吃
双亲委派模型是在类加载器的公共父类ClassLoad
中的loadClass()
方法中通过递归判断当前类是否被加载器过并找父类加载器直到引导类加载器,然后依次在返回递归调用前即递归的后序遍历挨个判断是否能被当前类加载器加载,能加载直接加载返回,如果不能加载就返回null
,子类加载器再判断是否能加载,如果所有类加载器遍历了都无法加载则抛出ClassNotFoundException
双亲委派机制的代码实现逻辑
🔎:双亲委派机制在java.lang.ClassLoader
的loadClass(String,boolean)
方法中实现的,具体的实现逻辑如下
注意即使是核心API
的类也是从系统类加载器依次向上找父类直到找到引导类加载器
在当前类加载器的命名空间中查找有无当前类的class
对象,如果有直接返回
判断当前类加载器的父类加载器是否为null
,不为null
说明父类加载器不是引导类加载器,调用父类的loadClass(String,boolean)
方法尝试让父类去加载当前类
如果当前类加载器的父类加载器为null
即是引导类加载器,则调用findBootstrapClassOrNull(name)
尝试让引导类加载器判断是否加载过并尝试进行加载
如果父类加载器无法成功加载当前类,则当前类加载器调用findClass(name)
判断当前类加载器是否能进行加载,如果能加载会调用ClassLoader
中的本地方法defineClass()
方法加载目标Java
类
为了保护系统不因为引入全限定类名相同的类就导致项目使用的类被篡改导致系统崩溃,引入双亲委派机制;
自定义类与核心包存在类全限定类名相同虽然是错误写法但是不会报错
比如如果系统注入了一个与Java
核心包全限定类名相同的类,此时由于双亲委派机制会由引导类加载器去加载核心包路径下JDK
提供的那个类而不会去加载全限定类名相同的用户自定义类,核心包中的类全都没有main
方法,如果用户自定义类的全限定类名和核心包的类相同还有main
方法,会直接报错该类中没有main
方法,因为只会去加载核心包的同名类
注意如果自定义的类的全限定类名在核心包没有,但是包名和核心包一样也会报错,因为Java
不允许用户自定义类使用核心包名,这是为了避免用户通过让自定义类和核心包的包名相同使用引导类加载器来加载自定义类,避免用户对引导类加载器进行攻击
注意核心包下一些类只有接口,自实现由第三方提供,这些实现类使用当前线程上下文Thread.currentThread().getContextClassLoader()
获取类加载器执行类加载,默认是应用类加载器
双亲委派机制优势和弊端
优势
类加载器有层次关系导致类也有层次关系,双亲委派机制可以,确保一个类只能被唯一确定的类加载器加载,而且加载前还会去检查类加载器的命名空间中是否因为类已经被加载过有对应的class
对象,确保类的全局唯一性避免类被重复加载
保护系统安全,率先使用引导类加载器加载核心包防止用户通过命令相同全类名的类来篡改核心API
,不允许使用核心包包名防止引导类加载器被用户随意使用[使用核心API
的包名会导致JVM
启动报错并抛出SecurityException
],这种对java
核心源代码的保护被称为沙箱安全机制
弊端
双亲委派模型会导致顶层的类加载器无法访问底层类加载器所加载的类,这会导致系统核心类无法访问用户自定义的应用类,比如JDK
在系统类中提供一个接口,该接口在应用类中得到实现,该接口还绑定了一个工厂方法用于创建接口的实例,但是因为接口和工厂方法都在启动类加载器中,此时会出现该工厂方法无法创建由应用类加载器加载的应用实例的问题
JVM
只是建议采用双亲委派模型,并没有明确要求,Tomcat
的类加载器使用的加载机制和传统双亲委派模型有一定区别,当缺省的类加载器接收到一个类加载器任务首先会自行进行加载,加载失败了才会将类的加载任务委派给其父类加载器去执行,这也是Servlet
规范推荐的做法
双亲委派机制的破坏方式
🔎:实际开发中有很多场景都需要上层类加载器加载的类需要使用到下层类加载器加载的类的实现,需要避开双亲委派模型单向委托的行为,有三种典型破坏双亲委派机制的行为
1️⃣JDK1.2
以前不满足双亲委派模型,但是类加载器包括抽象类ClassLoader
在JDK1.0
就有了,用户本来就是直接覆盖loadClass()
方法自定义类加载逻辑,因此只有重写loadClass
方法就能破坏调双亲委派机制,而且那时重写类加载逻辑就要重写loadClass
方法;
对于该问题,JDK1.2
在ClassLoader
中添加了一个protected
的findClass()
方法,引导用户将类加载器逻辑编写到该方法中,而不是直接在loadClass()
方法中直接编写代码,将双亲委派的实现编写在loadClass
中,在loadClass
方法中当父类加载器加载失败,子类加载器再调用findClass()
方法来尝试加载,实现既按照用户自己定义的方式去加载字节码二进制信息流,也能保证自定义类加载器是满足双亲委派机制的
2️⃣线程上下文类加载器
双亲委派机制存在缺陷,JDK
的核心API
总是由顶层的类加载器加载,但是如果核心API
想要回调用户类中的代码就无法实现了[核心API
需要引导类加载器加载,但是接口的实现需要由系统类加载器进行加载,引导类加载器不认识、无法加载和调用这些应用类],比如Java
的标准服务JNDI
的代码有引导类加载器加载,但是JNDI
的设计目的就是对资源进行查找和集中管理,需要调用其他厂商实现并部署在应用程序的ClassPath
下的JNDI
服务提供者接口[SPI:Service Provider Interface
,Java
把核心类rt.jar
中提供外部服务可由应用层自行实现的接口称为SPI
],Java
为了解决这个问题引入了不太优雅的线程上下文类加载器[Thread Context ClassLoader]设计[默认情况下线程上下文类加载器就是系统类加载器],
当父类加载器想用子类加载器加载的类时委托线程上下文类加载器去调用子类加载器加载的类,这个过程的目的是父类加载器以线程上下文类加载器为中介去请求子类加载器完成类加载的行为从而实现在引导类加载器加载会调用用户实现类中方法的SPI
核心类时由引导类加载器去请求子类加载器尝试加载需要调用的用户类,这个过程违背了双亲委派模型的原则,Java
中的SPI
比如JNDI
、JDBC
、JCE
、JAXB
、JBI
都是使用这种方式来实现的,JDK6
为了消除这种极不优雅的实现方式,提供了java.util.ServiceLoader
类以META-INF/services
中的配置信息和责任链模式才给SPI
的加载提供了相对合理的解决方案
3️⃣这种方式是由于用户对代码热替换、模块热部署等对程序动态性的追求导致的,所谓的热部署、热替换就像电脑一样不需要停机就能替换鼠标键盘等外设
2008
年IBM
主导的JSR-291
的项目即OSGI R4.2
针对模块化部署的标准把Oracle
和SUN
公司按在地上摩擦,Oracle
公司不服气在Java9
引入了jigsaw
模块化新特性才渐渐实现了Java
模块化事实上的标准,此时OSGI
已经比较成熟了,这里主要介绍OSGI
实现的热部署
OSGI
实现模块化热部署的关键是其实现的自定义类加载器机制,让每一个Bundle
程序模块都有一个字节的类加载器,这些自定义的类加载器不再满足双亲委派模型推荐的树状结构,采用更进一步发展的更加复杂的网状结构,更换Bundle
即更换程序模块时把Bundle
连同类加载器一起换掉实现代码的热替换
OSGI
只针对部分类使用双亲委派模型,其余的类都是在平级的类加载器中进行,OSGI
的类加载器设计不符合双亲委派模型原则,而且为了实现热部署带来了很高的复杂度,但是业界技术人员都认为弄懂了OSGI
对类加载器的运用才算是掌握了类加载器的精髓
热替换:在不停止服务的前提下通过替换程序文件来修改程序的行为,大部分脚本语言比如PHP
天生就支持热替换,只要替换了PHP
源文件,无需重启WEB
服务器改动就会立即生效;Java
中最初不支持热替换,因为修改了类文件无法让系统自动重新加载被修改的类,只能通过自定义类加载器来实现这个功能,此外不同的类加载器加载同一个字节码文件在JVM
内部也会认为这是两个完全不同的类。Java
可以自定义类加载器,通过自定义类加载器在一定条件下触发对修改后的字节码文件的加载并替换掉旧的Class
实例从而实现不停机的情况下程序的热替换,这种多个自定义类加载器实例加载同一个字节码文件生成不同class
实例的方式也破坏了双亲委派模式
沙箱安全机制
沙箱安全机制的作用是保证原生的程序运行环境的安全,沙箱就是限制程序运行的一个环境,沙箱机制是将Java
程序限定在JVM
特定的运行范围中,严格限制代码对本地系统资源如CPU
、内存、文件系统、网络的访问,保证代码的有限隔离,防止对本地系统造成破坏,不同级别的沙箱对资源的访问限制不同,所有的Java
程序的运行都可以指定沙箱并定制安全策略
JVM
沙箱安全机制的变更
JDK1.0
本地代码可以访问一切本地资源,远程代码被认为是不受信任的被限制在沙箱环境中严格限制对本地系统资源的访问
缺陷是影响程序的功能扩展,用户无法实现通过远程代码访问本地系统文件,
JDK1.1
增加了安全策略,允许用户指定远程代码对本地系统资源的访问权限,受信任的远程资源可以访问本地系统资源
JDK1.2
增加了代码签名,设置了多个不同的权限组,不管是本地代码还是远程代码都可以统一分配到不同的权限组中分类管理不同代码对本地系统资源的访问权限,不同权限组的权限不同
JDK1.6
的沙箱安全机制引入了域的概念,域可以分为系统域和应用域,系统域专门负责与系统资源的交互,应用域通过系统域代理对系统资源的访问,JVM
中不同的应用域都对应不同的权限,代码会被加载到不同的系统域和应用域中,不同域中的类文件具备当前域的全部权限
垃圾回收称GG
[Garbage Collection]、垃圾回收器也称GC
[Garbage Collector]
垃圾收集方法和垃圾收集算法有哪些以及各自的特点
虚拟机栈不需要垃圾回收,调用方法过程中执行方法入栈,方法执行完毕出栈即可;可以认为95%的垃圾回收集中在堆区,5%集中在方法区[JDK8
以后使用元空间本地内存替代方法区,内存大,一般不会出现内存溢出问题]
串行和并行:用户线程和垃圾回收线程不能同时执行,串行即垃圾回收线程只有一条,并行指垃圾回收线程有多条
并发指垃圾回收线程和用户线程可以同时执行[即不会出现所有用户线程出现stop-the-world
的情况]
垃圾回收的意义:避免内存被耗尽,清除内存碎片,整理出新的大段内存分配给新的对象,除了Java
,C#
,Python
、Ruby
都使用自动内存分配和自动垃圾回收的方式
如果不对内存中的垃圾进行处理,这些垃圾对象会一直保持到应用程序结束,最终可能导致内存溢出
内存泄漏:对象本身不再使用,但是试图回收的时候发现对象还存在指向的引用比如C/C++
的忘记释放内存导致没有办法被回收,引用计数算法中没有特意处理循环引用的问题导致内存泄漏,Java
中定义的内存泄漏就是用户已经不会再使用某个对象,但是垃圾回收时还是发现可达性分析时对象还是可达的
自动的内存分配和垃圾回收会弱化开发人员的内存溢出时的定位和解决问题的能力,需要使用监控工具对自动化技术实施必要的监控和调节
垃圾回收算法
垃圾标记阶段
引用计数算法
概念:每个对象都有一个整形引用计数器属性,记录对象被引用的次数,每有一个对象引用了该对象,引用计数器就加1,每当一个引用失效,引用计数器就减1,引用计数器的值为0,表示对象不可能再被使用,可以进行垃圾回收,只要发现引用计数器为0,不需要等待内存区域空间不足就会直接回收
优点:实现简单,垃圾对象便于标识,相较于可达性分析算法效率高,回收没有延迟性
缺点:
引用计数器属性增加了内存空间上的开销,每次新增或者销毁对象引用都会对引用计数器进行更新,增加了运行性能开销
引用计数算法最致命的问题是没法处理循环引用的问题,导致Java
的垃圾回收器没有使用该算法
循环引用是指比如一个引用指向一个环形链表或者两个对象互相指向彼此,销毁引用时,环形链表中的所有对象都被环形链表中的其他对象引用,因此环形链表并不会被销毁,导致整个环形链表内存泄漏
通过两个对象循环引用也能证明Java
没有使用引用计数算法
Python
使用了引用计数算法,Python
解决循环引用的问题主要通过手动解除和弱引用两种方式;手动解除就是显式将循环引用的指针销毁;弱引用是Python
提供的标准库,循环引用使用弱引用,只要发现对象引用是弱引用就对对象进行回收
可达性分析算法[根搜索算法、追踪性垃圾收集]
可达性分析算法最重要的就是解决在引用计数算法中出现的循环引用问题,被Java
、C#
使用
概念:内存中的存活对象都会被根对象集合中的根对象直接或者间接的连接,以根对象集合GC Roots
作为起始点,在集合中从上到下从根对象开始搜索,检查根对象引用了哪些对象实体,这些对象实体又引用了哪些对象实体,整个从根对象开始的树上的对象都被认为是存活对象,存活对象被认为是可达的;垃圾对象无法通过根对象访问到,即没有和引用链相连的对象认为是不可达的,被认为对象已经死亡,可以被标记为垃圾对象
根对象集合是一组必须活跃的引用,GC Roots
中包含以下元素,判断技巧是如果一个指针指向堆内存中的对象但是又不存放在堆内存中,指针指向的对象就是一个根对象
虚拟机栈中局部变量表中的引用数据类型引用
本地方法栈中引用数据类型引用
类静态属性引用
串池中引用
同步监视器[同步对象]即同步锁持有的对象
JVM
内部的引用比如基本数据类型对应的class
对象,常驻的像空指针、内存溢出等异常对象,系统类加载器
反映虚拟机内部情况的JMXBean
、JVMTI
中注册的回调、本地代码缓存等
GCRoots
中还有可能加入一些临时的对象,比如只对堆的新生代进行局部垃圾回收,非新生代比如老年代或者其他关联区域的对象也会临时加入到GC Roots
中去考虑来保证可达性分析的准确性
从根对象开始搜索走过的路径被称为引用链
可达性分析需要在一个保证一致性的快照中进行,不能一边分析引用关系根节点集合还在不断地变化,因为这点导致GC
时必须STW
停下所有用户线程,即使是号称不会停顿的CMS
低延迟并发垃圾收集器枚举根节点也必须要停顿下来
对象的finalization
对象终止机制
目的是允许开发人员在object.finalize()
方法中自定义对象销毁之前的额处理逻辑,一般是用于对象在被回收前对计算机资源进行释放,比如关闭文件、关闭套接字和数据库连接等
不要试图去主动调用一个对象的finalize()
方法,应该交给垃圾回收器进行调用,原因主要有以下三点
主动调用finalize()
方法可能导致对象复活
主动调用finalize()
方法也是由fianlizer
线程执行,该线程优先级比较低,即使主动调用也不一定会立即被执行,极端情况下不发生GC
,finalize()
方法没有执行的机会
重写fianlize()
方法如果性能太差,或者陷入死循环,会严重影响GC
的性能
因为finalize()
方法执行时机的存在,JVM
中的对象一般有三种可能的状态;一个不可达的对象也不是必须被销毁,在某个条件下不可达对象可能被复活
可触及状态:对象可达
可复活状态:对象所有的引用都被释放,对象不可达,但是对象可能在finalize()
方法中被复活
不可触及状态:对象不可达,且finalize()
方法被调用后没有复活对象就会进入不可触及状态,此后对象不可能被复活,只要用户不主动调用对象的finalize()
方法,该方法在对象生命周期中只会被调用一次
机制流程
如果对象obj
到GC Roots
没有引用链,进行第一次标记
被标记的对象判断是否有必要执行finalize()
方法
obj
没有重写finalize()
方法,或者fianlize()
方法已经被虚拟机调用过了,则finalize()
方法不会被执行,obj
会被判定为不可触及
如果obj
重写了finalize()
方法且没有被执行过,obj
会被插入到F-Queue
队列中,由JVM
创建的一个低优先级的fianlizer
线程触发fianlize()
方法的执行
finalize()
方法执行完GC
会对obj
进行第二次标记,如果obj
在finalize()
方法中变得可达,第二次标记时,obj
会被移出即将被回收对象的集合;此后一旦obj
再次不可达,此时被第一次标记后不会再调用finalize()
方法,对象直接变成不可触及状态,然后再被第二次标记直接被销毁
MAT
的GC Roots
溯源
显示GC Roots
方面,Jvisualvm
没有MAT
这么方便,找到GC Roots
通过引用链来判断本该被回收的对象哪个位置存在内存泄漏
MAT
[Memory Analyzer
]:是基于Eclipse
开发的免费性能分析工具,用于查找内存泄漏以及查看堆内存的消耗情况,和jconcolse
、jProfiler
、jvisualvm
等功能类似
MAT
可以离线分析通过命令行jmap -dump:format-b,live,file=test1.bin 进程号
命令生成或者jvisualVM
的监视选项卡下的堆Dump
导出的离线dump
快照文件[Jvisualvm
关闭dump
文件就没了,需要另存为才能保存]
通过JVM
参数-XX:+HeapDumpOnOutOfMemoryError
可以在系统发生OOM
时会在当前模块根目录下自动生成以.hprof
结尾的dump
文件
流程
生成离线Dump
文件
MAT
-File
-open File
打开dump
文件,蓝色图标下拉列表--Java Basics
--GC Roots
就能看到根对象集合中的所有对象
Eclipse
对GC Roots
中的对象采用了自定义的分类标准,通过线程可以查找当前线程下的所有根对象
JProfiler
的GC Roots
溯源
流程
JProfiler
可以在Heap Walker
中动态显示每个类的对象实例数量,针对某个类点击Show Selection In Heap Walker
可以单独展示一个类下的所有对象实例
点击Refernces
选项卡,选择Incoming refernces
点击Show Paths To GC Root
可以展示当前对象对应的根对象
使用JProfiler
可以直接打开dump
文件
通过Biggest Objects
可以查看每个对象的大小,可以直接定位到因为对象太大导致的内存溢出的对象
通过Heap Walker
的Thread Dump
可以查看哪个线程出现了OOM
异常
垃圾清除阶段:执行死亡对象的垃圾回收,释放掉无用对象占用的内存空间
标记清除算法[Mark-Sweep
]
1960
年提出并被应用到Lisp
语言中,堆中有效内存空间被耗尽,STW
停止所有用户线程,标记所有垃圾,然后对垃圾进行清除
标记:GC
从引用根对象开始遍历,标记所有的可达非垃圾对象,标记会记录在对象的对象头中
清除:GC
对堆中所有对象进行线性遍历,将没有被标记的非可达对象进行回收,所谓的清除也只是将需要清除的对象地址保存在空闲地址列表中,为新对象分配内存时判断某块垃圾位置大小是否足够,够就直接覆盖掉垃圾数据,回收实际上回收的是地址,垃圾数据在写入新数据的时候才会清除
优点:容易理解
缺点:
标记遍历所有可达对象时间复杂度O(N)
,清除阶段遍历所有对象时间复杂度O(N)
,效率相较于其他两种算法不高
GC
时会停止整个应用程序,用户体验差
清理出来的空闲内存不连续,会产生内存碎片,导致内存不规整为对象分配内存需要维护一个空闲列表
复制算法
1963
发布论文并由论文发布者本人引入到Lisp
语言的一个实现版本中
原理:准备两块空间可用的内存空间,一次只使用其中一块,垃圾回收时标记阶段将存活的对象规整复制到未被使用的内存块,交换两个内存块的角色完成垃圾收集,新生代的幸存者区就使用的复制算法
优点:
没有标记和清除过程,实现简单,运行高效
垃圾收集后的内存空间规整,不会出现碎片问题
缺点:
需要两倍的内存空间
对象移动后引用地址会发生变化,垃圾回收还需要重新改变引用对应对象的地址值,内存和修改引用上的开销也不小
对于只有少量存活对象的场景表现还行,但是如果极端情况下,所有的对象都存活,此时复制算法相当于把所有对象原封不动重新拷贝到另一块内存空间,而且还要重新改变所有对象的引用地址值,无效开销非常严重;因此复制算法只适用于内存充足且每次垃圾回收存活对象非常少的情况,比如新生代这种死亡率达到70%-99%
的内存空间使用复制算法比较合适,也靠频繁的GC
来降低双倍内存的开销;像老年代这种对象存活时间久就不适合使用复制算法
标记压缩算法[Mark-Compact
、标记整理算法]
标记清除算法能用在老年代,但是对象过大会直接进入老年代,标记清除算法内存碎片太多,如果要使用标记清除算法,本身效率就不高,还要专门对内存进行规整处理
标记压缩算法是在标记清除算法的基础上进行的优化,1970
年发布了标记压缩算法,很多现代的垃圾收集器中都使用的标记压缩算法及其改进版本
原理:
标记:从根节点开始标记所有被引用的对象
压缩:将所有存活的对象整理到内存的一端按顺序排放
优点:
内存规整,使用指针碰撞来为对象分配内存无需使用空闲列表来记录空闲内存的地址,消除了复制算法内存减半的高额代价
缺点:
效率低,甚至比标记清除算法还低一些,比复制算法多了一个标记环节,比标记清除算法多了一个垃圾整理环节
需要更改移动后的对象的引用地址
移动过程也需要STW
,暂停用户线程的时长也会长一些
分代搜集算法
分代搜集算法不是一个真正的算法,只是基于不同对象生命周期不同策略性地选择不同的搜集方式提高垃圾的回收效率[跟业务挂钩的对象生命周期就会长一些,程序运行期间产生的临时变量生命周期就短一些,不可变类的特性每次更新操作都会创建对象决定了这些类的生命周期比较短]
目前几乎所有的垃圾回收器都是采用分代收集算法实现的,堆区一般分成新生代和老年代两个区域,几乎所有的垃圾回收器都区分新生代和老年代
新生代区域小,对象生命周期短,存活率低,回收频繁;回收频繁要求算法效率高、存活率低对应存活对象少,正好适合使用复制算法;复制算法的弊端内存开销大,通过细分新生代,引入幸存者区屏蔽掉生命周期极短的对象缓解内存浪费;新生代占堆区的1/3
,单个幸存者区占新生代的1/10
,只有1/30
的内存额外开销
老年代区域大,对象生命周期长,存活率高,回收频率低;一般使用标记清除算法和标记整理算法混合实现,CMS
是针对老年代的垃圾回收器,基于标记清除算法实现,对于内存碎片问题的解决是当由于内存碎片导致对象分配问题时,补偿使用基于标记压缩算法的Serial Old
垃圾回收器执行Full GC
整理老年代的内存
增量收集算法
分代收集算法STW
的时间比较长,严重影响用户体验和系统稳定性,增量收集算法为了解决STW
时间长的问题
原理:
每次垃圾收集线程只收集一小块内存空间,接着继续执行用户线程,依次反复直到垃圾收集完成,即把集中在一块的STW
时间分散到一段时间区间内,增量收集算法协调垃圾收集线程和用户线程的执行
缺点:
频繁在垃圾回收线程和用户线程间进行上下文切换,会增加垃圾回收的整体成本,造成垃圾回收器吞吐量的下降
分区算法
分区算法将整个堆空间划分成不同的小区间[region
],每个小区间独立使用,独立垃圾回收,有的小区间作为伊甸园区、有的小区间作为幸存者区,有的小区间作为老年代,有的小区间放大对象;根据GC
的指定时间区间可以控制单次垃圾回收回收多少个小区间,降低GC
导致的停顿间隔
实际的GC
实现过程要复杂的多,前沿GC
都是复合算法,并且并行和并发兼备[并行指多个垃圾回收线程、并发指垃圾回收线程和用户线程同时运行]
三种垃圾清除算法的对比[没有最好的算法,都有优缺点,需要根据应用场景选择使用]
速度:复制算法最快、标记清除算法中等、标记压缩算法最慢
内存开销:复制算法需要活对象两倍的开销、标记清除算法和标记压缩算法都是正常开销,标记清除算法有内存碎片,标记压缩算法没有内存碎片
移动对象:复制算法和标记压缩算法都会移动对象,标记清除算法不会移动对象
GC对象的判定方法有哪些,即如何判断对象已死亡
垃圾:程序运行期间没有任何指针指向的对象,不够准确,在可达性分析中不可达的对象,即无法通过GC Roots
根对象集合中的对象访问到的对象
可达性分析、finalize
方法、各类引用关联对象的状态和生命周期
如何判断一个常量是废弃常量
简述GC流程,对象如何晋升到老年代
简述常见垃圾回收器的优缺点,重点阐述CMS
和G1
[原理、流程、优缺点]
GC
没有被JVM
规范要求必须使用具体的垃圾回收器,很多厂商都有自己的实现,发展至今已经衍生了众多GC
版本,JDK
每个版本的更新都会提到GC
的变化
垃圾回收器的分类
按垃圾回收线程的线程数分可以分为串行和并行垃圾回收器,串行垃圾回收器适合单CPU或者硬件不是特别优越的场合,串行回收器的性能表现比并行或者并发回收器表现更好,串行回收器默认工作在在客户端模式下的JVM
中
按照工作模式可以分为并发式垃圾回收器和独占式垃圾回收器,独占式指垃圾回收线程工作时其他用户线程全部暂停;并发式指垃圾回收线程和用户线程交替工作,减少用户线程的单次停顿时间
并发垃圾回收器一次垃圾回收期间部分多段时间只能垃圾回收线程工作,部分时间垃圾回收线程可以和用户线程同时工作
按照碎片的处理方式可以分成压缩式垃圾回收器和非压缩式垃圾回收器,压缩式指垃圾回收完以后是否对存活对象进行规整整理,压缩式垃圾回收使用指针碰撞的方式再分配对象空间,非压缩式垃圾回收使用空闲列表的方式再分配对象空间
按照处理的内存区域可以分为年轻代垃圾回收器和老年代垃圾回收器
新生代垃圾回收器只能回收新生代:Serial
、Parallel Scavenge
、ParNew
老年代垃圾回收器只能回收老年代:Serial Old
、Parallel Old
、CMS
整堆收集既可以回收新生代也可以回收老年代:G1
JDK8
及以前可选择的组合关系:Serial
+CMS
/Serial Old
;ParNew
+CMS
/Serial Old
;Parallel Scavenge
+Serial Old
/Parallel Old
因为CMS
是并发垃圾回收器,垃圾回收的同时用户线程还会继续执行,因此CMS
不能等到老年代空间满了再进行回收,如果确实回收晚了或者垃圾制造速度比回收速度快导致CMS
垃圾回收失败,此时会选择Serial Old
作为后备方案来容错,因此使用CMS
需要使用Serial Old
作为兜底垃圾回收器
JDK8
中Serial
+CMS
和ParNew
+Serial Old
的组合过时但是还可用,JDK9
中这两个组合完全被禁止使用
JDK14
中Parallel Scavenge
+Serial Old
过时但是还可以使用,CMS
被移除,这是因为JDK9
中G1
被引入替代了CMS
JDK8
中的默认组合是Parallel Scavenge
+Parallel Old
Parallel Scavenge
底层使用的框架和CMS
不同,导致两个不能组合使用
存在多种不同组合的原因是Java
在移动端和服务器端都有很多应用场景、针对不同场景的要求对垃圾回收器的要求不同,没有万能的垃圾回收器,只能选择针对具体应用最合适的垃圾回收器
GC
的性能指标:吞吐量、暂停时间和内存占用三者是相互矛盾的关系,暂停时间的重要性越来越重要,随着硬件技术的发展,内存占用可以越来越大,硬件性能的提升也促进吞吐量的提升,但是内存的扩大也导致STW
的时间更长;现在GC
优化的重点就是降低STW
的时间实现低延迟;针对不同场景具体要求也不同,像Parallel
并发垃圾回收器更关注吞吐量、CMS
、G1
、ZGC
更多地关注暂停时间;设计或使用GC
算法时必须确定GC
是更专注高吞吐量还是低延迟,在二者之间找到一个合适的折中点;像G1
的标准是在优先降低暂停时间控制暂停时间的最大值的基础上再考虑提升吞吐量
吞吐量:总运行时间等于程序运行时间加内存回收时间,吞吐量为程序运行时间占总运行时间的比例
吞吐量越高,程序对暂停时间的要求就越低,通过减少GC
线程活跃的频率来提升吞吐量会导致单次暂停时间的增加导致高延迟
服务器更看重吞吐量
垃圾收集开销:内存回收时间占总运行时间的比例
暂停时间:单次STW
的时间,即执行垃圾收集时用户线程被暂停的时间
暂停时间通过降低每次GC
的内存大小,增大GC
的频率来实现,总的吞吐量会降低,总的STW
时间因为线程上下文切换会增加,因此低暂停时间会拉低吞吐量
200ms
的暂停时间都可能打断终端用户的体验,交互式的客户端应用程序更多关注低暂停时间即低延迟
收集频率:垃圾收集的频率
内存占用:堆区大小
快速:对象从诞生到被回收经历的时间
垃圾回收器的发展历史
1999
年随JDK1.3
发布的串行垃圾回收器Serial
,是第一款垃圾回收器,在单核CPU和客户端场景下的性能表现不错;ParNew
是Serial
的多线程即并行版本,在服务端性能更好一些
2002
年随JDK1.4
发布Parallel GC
和Concurrent Mark Sweep GC
[CMS
],Parallel
在JDK6
成为HotSpot
的默认GC
,适用于新生代的GC
,老年代使用Parallel Old GC
;关注低延迟的场景下更多地选择CMS
;Parallel
和Parallel Old
搭配使用,但是不能和CMS
搭配使用,CMS
可以和ParNew
搭配使用;在不同生产环境下,可以使用parallel
和parallel Old
组合或者ParNew
和CMS
组合;在硬件性能比较低的场合下才会考虑Serial
和Serial Old
的组合
2012
年随JDK1.7
发布了G1
,2017
年JDK9
中G1
取代CMS
成为HotSpot
的默认垃圾回收器,CMS
被提示过时且后续将会废弃,2018
年G1
通过并行垃圾回收来改善垃圾回收器的延迟,实现在限定暂停时间的前提下尽可能提高吞吐量
G1
的特点是兼具回收新生代和老年代,G1
使用的是分区算法
2018
年随着JDK11
发布引入Epsilon
垃圾回收器和实验性的可伸缩的低延迟垃圾回收器ZGC
,也称No-op
无操作垃圾回收器
2019
年JDK12
发布在Open JDK
中引入了红帽公司研发的Shenandoah GC
,该垃圾回收器也是专注于低延迟,同时对G1
和ZGC
做自动返回未使用对内存给操作系统
2020
年随着JDK14
的发布删除了CMS
垃圾回收器,即使配置了CMS
也会使用默认的G1
,但是JVM
不会报错只会警告,此前ZGC
只能使用在Linux
系统上,2020
年扩展了ZGC
可以在mac
和windows
上使用
七款经典垃圾回收器[所谓经典就是已经被商用检验的]
串行回收器:Serial
、Serial Old
并行回收器:ParNew
、Parallel Scavenge
、Parallel Old
并发回收器:CMS
、G1
新时期还在发展中处于实验阶段的垃圾回收器:Shenandoah
、ZGC
、Epsilon
选择垃圾收集器的策略
客户端或者嵌入式这种内存和CPU
资源贫瘠选择Serial
吞吐量最大化选择Parallel
低延迟暂停时间最小化选择CMS
垃圾收集器的选择指标[国内只有像阿里这种需要需要优化底层垃圾收集器的底层算法、一般的程序员只需要关注根据应用场景选择合适的垃圾收集器]
优先调整堆大小让JVM
自动调整,不一定特别好但是一定不会差
内存比较小,使用串行GC
单核单机程序,并且没有暂停时间要求使用串行GC
多核、要求高吞吐量、允许暂停时间超过1s
,可以选择parallel
并行垃圾收集器
多核、低暂停时间、互联网应用这类需要快速响应的场景,选择G1
这种并发GC
,现在的互联网项目基本都使用G1
小结
新生代都是复制算法、老年代除了CMS
是标记清除算法其他都是标记压缩算法
在JDK14
及以后能使用的组合只有Serial
+Serial Old
;Parallel
+Parallel Old
;G1
GC
发展阶段:
Serial
Serial
曾经作为HotSpot
的Client
模式下的默认新生代垃圾回收器,基于复制算法、串行回收和STW
机制的串行垃圾回收器
Serial
还提供用于老年代垃圾收集的Serial Old
垃圾收集器,基于标记压缩算法、单垃圾回收线程串行回收和STW
,Serial
曾经作为HotSpot
的Client
模式下默认的老年代垃圾回收器,Serial Old
在Server
模式下主要与Parallel Scavenge
组合作为老年代垃圾回收器以及作为CMS
的兜底垃圾收集方案
多核CPU场景下依然可以选择Serial
,但是效率不高,一般不会这么设置;只有单核CPU场景下才会用,现在客户端设备单核CPU也很少了,基本上只有嵌入式设备才会考虑
HotSpot
虚拟机可以使用JVM
参数-XX:+UseSerialGC
指定年轻代和老年代都使用Serial
垃圾收集器,注意没有JVM
参数-XX:+UseSerialOldGC
,使用启动会报错
Serial
发布古老,设计简单,但是Serial
不管是垃圾回收相关数据结构还是垃圾回收线程上的开销都非常小,随着云计算的兴起,在Serverless
等新的应用场景下被广泛使用
优点:
单CPU场景下节省了线程上下文切换的开销,单CPU场景下效率相较于其他垃圾回收器更高,适合运行在JVM
的Client
模式下,适用于交互性较强的场景下
缺点:
因为该垃圾收集器时串行的,暂停时间太长,JavaWeb
应用程序完全不会考虑串行垃圾收集器
ParNew
ParNew
是Serial
的多线程版本,底层共享了Serial
的很多代码,同样在新生代中采用复制算法,STW
机制,因为多条垃圾回收线程进行垃圾收集,STW
的时间会短一些
ParNew
曾经作为很多JVM
在Server
模式下的新生代默认垃圾收集器,后续随着和Serial Old
组合在JDK9
的过时以及JDK14
中CMS
的移除几乎落幕,JDK9
开始配置在JVM
中使用ParNew
就会警告不建议使用,在将来会移除
新生代回收频繁,使用并行方式更高效
CPU
多核场景下ParNew
效率更高,单核场景下没有Serial
高效,因为线程上下文切换还会有额外的开销
使用JVM
参数-XX:+UseParNewGC
指定使用ParNew
垃圾收集器,该参数只会指定新生代使用ParNew
,不会影响老年代;通过JVM
参数-XX:ParallelGCThreads
可以设置垃圾回收线程的数量,默认垃圾回收线程的数量是CPU
的核心数,建议不要超过CPU
核心数,避免多个线程竞争同一个核
Parallel
Parallel
和ParNew
都一样采用复制算法、并行回收和STW
机制,性能上差别也不大;主要区别是parallel
的侧重吞吐量,在吞吐量达到一定值以后尽可能提升暂停时间;而ParNew
的是在满足指定暂停时间的前提下尽可能提升吞吐量,并且parallel
和parallel Old
采用的底层框架也不同,导致Parallel
和CMS
不能组合使用
此外parallel
相较于ParNew
多了一个自适应调节策略,能够动态调整堆内存分配情况来配合实现高吞吐量
高吞吐量适合不需要太多交互任务的后台运算场景,适合执行批量任务、订单处理、工资支付、科学计算等服务器应用程序
Parallel
在JDK1.6
提供了Parallel Old
处理老年代的垃圾收集代替原来适合单核场景下的Serial Old
[parallel
因为架构问题不能和CMS
组合使用],因为ParNew
和CMS
搭配比Parallel
和Serial Old
搭配更好,补上了parallel Old
就可以名正言顺的用Parallel
,因此Parallel
和Parallel Old
也是JDK8
中Server
模式下默认的垃圾回收器
parallel Old
使用标记压缩算法,基于并行回收和STW
机制
相关JVM
参数
-XX:+UseParallelGC
和-XX:+UseParallelOldGC
分别是手动指定新生代使用Parallel
和老年代使用Parallel Old
,只要其中一个被开启,另一个也会自动开启;
-XX:ParallelGCThreads
设置新生代Parallel
的线程数,默认情况下,CPU核数小于8,ParallelGCThreads
等于CPU核数;CPU核数大于8,ParallelGCThreads
等于(5*CPU核数)/8
向下取整后加3,比如12个核心对应10个垃圾回收线程
-XX:MaxGCPauseMillis
:设置最大暂停时间,为了将暂停时间控制在该指定值内,parallel
会自动调整堆大小和其他参数,因为parallel
因为注重吞吐量适合在服务端,因此建议谨慎配置该参数,因为该时间设置的比较小,JVM
就会自动调小堆内存大小,导致GC
频率增高;频率高了多了线程上下文切换的开销以及每次额外工作的开销导致整体吞吐量会下降
-XX:GCTimeRatio
:设置垃圾收集时间占总时间的比例即设置垃圾收集开销1/(N+1)
,设置的是1/(N+1)
中的N
,默认值为99,即垃圾回收的时间不超过程序运行时间的1%
,暂停时间越短越容易超过设定的该参数
-XX:+UseAdaptiveSizePolicy
:设置parallel
启用自适应调节策略,默认情况下该参数就是开启状态,会自动调整年轻代的大小、伊甸园区和幸存者区的比例,晋升老年代对象年龄阈值等参数,期望能尽量满足预设的吞吐量和停顿时间;使用parallel
通常会手动指定堆空间的大小
CMS
[Concurrent-Mark-Sweep
]
CMS
就如同其名字一样是基于标记清除算法实现的HotSpot
中第一款真正意义上的并发垃圾收集器,第一次实现了垃圾回收线程和用户线程同时工作;CMS
也会有STW
CMS
的关注点是保证暂停时间的前提下尽可能提升吞吐量,非常适合互联网站或者B/S
系统的服务端这类特别注重请求的响应速度的场景
因为CMS
和Parallel
底层框架不兼容,使用CMS
回收老年代时,新生代只能选择ParNew
或者Serial
中的一个;在JDK9
兼具并发并行特点的G1
发布前,CMS
的使用非常广泛,至今仍然有很多系统在使用CMS
工作原理:
1️⃣:单个垃圾回收线程进行初始标记,此时会STW
,持续时间非常短
初始标记的任务是标记出GC Roots
直接关联的根节点对象,因为只是对根节点对象进行标记,因此这个环节执行速度非常快
2️⃣:单个垃圾回收线程执行并发标记,此时不会STW
并发标记的任务是从根节点对象开始遍历所有存活对象,该过程耗时长但是不需要暂停用户线程
3️⃣:多个垃圾回收线程执行重新标记,此时会STW
因为用户线程在并发标记过程仍然会运行,因此并发标记完成后GC Roots
和原本部分存活的对象可能产生变化,JVM
采用的是三色标记法,完全没有被GC
访问过的对象会被标记为白色,被GC
访问过但该对象直接引用到的其他对象没有全部访问成功被标记为灰色,对象以及其直接引用的其他对象都被GC
访问到被标记为黑色,在初始标记和并发标记期间一些对象的直接对象因为还没来得及被创建被标记为灰色,被标记为黑色和灰色的对象都不会被回收,在并发标记期间因为引用关系变化还可能涉及到多标和漏标,
多标是对象已经被标黑或者标灰,但是在并发标记期间引用被断开变成不可达对象,这就是浮动垃圾,此外并发标记开始后创建的新对象会被直接标记为黑色,如果这部分对象在并发标记期间变成了垃圾也会直接变成浮动垃圾,浮动垃圾本轮GC
不会被清除,需要等到下一轮垃圾回收才会被清除,浮动垃圾也不会影响到垃圾回收的正确性;
漏标是并发标记过程灰色对象直接或间接断开白色对象的引用,黑色对象重新直接或间接引用被断开的白色对象,同时满足这两个条件由于GC
不会再去遍历已经被标黑的对象,这些白色对象在本轮GC
因为已经被灰色对象断开无法再被GC
访问,无法被标黑或者标灰,如果不进行处理这些对象会被当做垃圾回收,这会直接影响用户程序的正确性,是不可接受的;解决办法是并发标记期间对象引用被断开又被其他对象引用时将该对象放入特定的集合,并发标记结束后停止所有用户线程遍历重新标记集合中的对象;CMS
通过写屏障加增量更新[当对象有新引用插入时记录下新的引用对象等待遍历重新标记]的方式通过黑色对象重新引用白色对象记录下对象破坏对象被漏标的第二个条件;G1
通过写屏障加原始快照SATB
,当灰色对象断开白色对象的引用时记录白色对象到特定集合中等待被重新标记破坏对象被漏标的第一个条件,本质还是让垃圾回收按照并发标记前的快照来执行垃圾回收,应该被清理的垃圾当做浮动垃圾处理;ZGC
是通过读屏障的方式在读取对象的成员变量还没到断开引用就记录下该成员变量等待遍历重新标记破坏对象被漏标的第一个条件
重新标记的任务是对可能发生漏标的对象进行重新标记,多标的对象或者并发标记过程新产生的对象变成垃圾一律当做浮动垃圾等待下一轮GC
再清理,重新标记过程会STW
,但是停顿时间比初始标记阶段稍长,但是远比并发标记阶段时间短
这里有两篇三色标记算法和增量更新的博客,闲了再看看,12.垃圾收集底层算法--三色标记详解,一文读懂-JVM三色标记法与读写屏障
4️⃣:单个垃圾回收线程执行并发清除,此时不会STW
并发清除阶段的任务是清理掉标记阶段判定死亡的对象,因为不需要移动存活的对象,因此用户线程可以同时执行
5️⃣:单个垃圾回收线程执行重置线程,此时不会STW
特点:
只有并发标记和重新标记两个阶段进行了STW
,暂停时间非常短,目前没有任何一款GC
能做到完全不需要STW
因为垃圾回收期间用户线程还会继续执行,因此CMS
开始回收时需要确保用户线程有足够的内存可以使用,此前的垃圾回收器都是内存区域几乎满了再收集,而CMS
是堆内存使用率达到某一阈值就开始垃圾回收;如果CMS
垃圾回收期间预留的内存无法满足用户线程的需要,会出现Concurrent Mode Failure
失败,此时JVM
将会临时启用Serial Old
来替代CMS
进行老年代的垃圾收集,此时会远远增大暂停时间
因为CMS
基于标记清除算法,因此只能使用空闲列表执行对象内存分配,CMS
因为垃圾回收期间需要用户线程继续执行,因此如果使用标记压缩算法,规整对象的过程就必须暂停用户线程,这就会极大地增加STW
的时间
CMS
因为算法设计上的缺陷,目前用户群体虽然多,但是在JDK9
被标记为过时,JDK14
中被移除
优点
并发收集,STW
时间极短,垃圾收集导致的延迟低
缺点[为了达到低延迟在更频繁的Full GC
和固有的浮动垃圾内存占用两方面做了很大的牺牲,因此后面引入了基于分区算法的G1
解决CMS
的问题并替换CMS
]
产生内存碎片,可能导致大对象无法存入老年代不得不提前触发Full GC
,导致更频繁的Full GC
CMS
对CPU
资源非常敏感,并发阶段垃圾回收线程会占用CPU资源,垃圾收集期间系统的性能和请求的吞吐量会降低
CMS
无法处理浮动垃圾,本轮GC
并发标记阶段产生的浮动垃圾无法被CMS
重新标记,这些在并发标记阶段产生的所有新垃圾都会被留到下一轮GC
被处理,因此堆中有一部分空间始终会被浮动垃圾占用
浮动垃圾:多标的对象就是浮动垃圾
CMS
相关JVM
参数
-XX:+UseConcMarkSweepGC
:手动指定老年代使用CMS
垃圾收集器,开启该配置将自动配置-XX:+UseParNewGC
在新生代使用ParNew
垃圾回收器,同时会自动启用Serial Old
作为CMS
的兜底垃圾收集器
-XX:CMSInitiatingOccupancyFraction
:设置堆内存使用率CMS
垃圾回收阈值,在JDK5
及以前默认值为68
,老年代的空间使用率达到68%
就会执行一次CMS
回收,JDK6
及以后默认值为92
;内存增长缓慢的情况下可以设置一个比较大的阈值,能有效降低CMS
垃圾回收的触发频率;如果应用程序的内存增长非常快,应该设置一个比较低的阈值,避免频繁触发Serial
串行垃圾收集器,显著降低Full GC
的执行机会
-XX:+UseCMSCompactAtFullCollection
:指定在执行完Full GC
后对内存空间进行压缩整理
注意Serial Old
、Parallel Old
、G1
都能执行Full GC
,JVM
默认使用Serial Old
执行Full GC
,使用其他两种垃圾回收器由对应垃圾回收器执行Full GC
注意Full GC
可以选择压缩也可以选择不压缩
-XX:CMSFullGCsBeforeCompaction
:设置在执行多少次不压缩的Full GC
后对内存空间进行压缩规整
-XX:ParallelCMSThreads
:设置CMS
的垃圾收集线程数量。CMS
的默认垃圾收集线程数是(ParallelGCThreads+3)/4
,其中ParallelGCThreads
是新生代并行垃圾收集器的垃圾收集线程数量,CPU
资源紧张时,受CMS
垃圾收集线程的影响,应用程序的性能在垃圾收集期间表现可能非常糟糕
G1
G1
的设计思想是区域化分代式,在吞吐量方面parallel
性能表现不错,在暂停时间方面CMS
表现不错,随着内存和CPU的进一步发展和庞大复杂用户数越来越多的业务要求,需要更适应现代要求的更低暂停时间和更高吞吐量的垃圾收集器;G1
的设计目标是延迟可控的情况下尽可能提高吞吐量,基于分区算法能同时回收新生代和老年代的并行全功能垃圾收集器;主要针对配备多核CPU
和大容量内存的服务端应用机器,具备极高概率满足GC
停顿时间的同时还兼具高吞吐量的特性;在JDK7
移除了实现性标识,使用-XX:+UseG1GC
来配置启用,在JDK9
被设置为默认垃圾收集器
G1
的原理
G1
将堆分割为物理上不连续的很多不相关大小一致且在JVM
生命周期中大小不会发生改变的Region
区域,使用不同的Region
表示伊甸园区、幸存者0区、幸存者1区和老年代,这些区域逻辑上也不再是连续的区域了
单个Region
只能属于伊甸园区、幸存者区或者老年代中的一个角色,此外G1
还增加了新的内存区域Humongous
,用户在Humongous
中存储超过0.5
个region
的大对象,一个Humongous
的默认大小就是一个region
的大小,如果一个H
区放不下一个大对象,G1
会组合连续的H
去来存放大对象,如果找不到能够存放该大对象的逻辑上连续的H
区[注意H
区存放大对象只要求H
区逻辑上连续即可,其他的垃圾回收器大对象和普通对象一样也要求物理上是连续的],此时就会直接启动Full GC
,在逻辑上H
区从属于老年代;大对象因为新生代放不下会直接放在老年代,但是一个短期存在的大对象随着老年代一起GC
会长时间无效占用堆内存,
JVM
维护了一个空闲列表记录空白的Region
,空闲列表中的Region
被使用时可以变换成任意一种角色
因为Region
之间使用复制算法进行垃圾回收,因此Region
内部自然而然使用指针碰撞的方式为对象分配内存;此外单个Region
中也会分配TLAB
G1
会跟踪各个Region
的垃圾回收可以获得的空闲空间大小以及回收需要时间的经验值,在后台维护一个优先级列表,每次根据允许的收集时间优先去回收价值最大的Region
,因为这种设计方式侧重于回收垃圾最大量的区间,因为该垃圾收集器被命名为Garbage First
特点
兼具并行与并发
兼顾新生代和老年代垃圾收集:G1
仍然属于分代型垃圾收集器,会区分新生代和老年代,新生代中依然区分伊甸园区和幸存者区,但是不要求每个区域在逻辑上连续,也不坚持每个区域固定大小和固定数量;而且支持一个Region
动态地扮演单独一个不同种类的分代区域;只是基于分区算法兼顾回收新生代和老年代
空间整合:Region
之间通过复制算法进行垃圾回收,整体上可以看做由复制算法实现的标记压缩算法,避免了内存碎片,尤其当java
堆非常大的时候,G1
的优势会非常明显
可预测的停顿时间模型[软实时soft real-time
]:该停顿时间模型能让使用者在明确指定长度为M
毫秒的时间片段内,垃圾收集的时间不超过N
毫秒,吞吐量对应(M-N)/M
;G1
只选择部分区域进行垃圾回收,也不需要再所有用户线程全部暂停,按照Region
回收价值的大小,维护一个优先级队列,每次根据指定的暂停时间,动态地选择几个回收价值最大的Region
进行垃圾回收,保证在有限的垃圾回收时间内获取尽可能高的收集效率;G1
不一定能做到CMS
在最好情况下的低延迟,但是最差情况要比CMS
好很多;软实时指的是不要求垃圾回收时间必须小于指定时间,而是指有一定的把握在指定时间内完成
G1
提供YoungGC
、Mixed GC
和Full GC
三种垃圾回收模式,分别在不同条件下被触发
除G1
外的其他垃圾收集器都使用专门的优先级更低的垃圾收集线程执行并行GC
,G1
可以在垃圾回收线程处理速度慢的情况下自动调用应用线程来加速后台的GC
工作
G1
随着JDK
版本迭代不断地改进,在JDK10
中将串行的Full GC
改成了并行Full GC
,很多场景下表现都会略优于Parallel
的并行Full GC
使用场景
大内存、多处理器机器的服务端应用,普通大小的堆中表现平平;为低GC
延迟的大堆应用提供垃圾回收方案
当堆大小大于6GB
,暂停时间可以低于500
ms,通过每次只清理一部分region
的增量式清理保证每次GC
的暂停时间不会过长
适合使用G1
替换CMS
的场景:
超过50%
的Java
堆空间都是活跃数据
新对象分配频率或者对象年龄提升频率很高
GC
暂停时间长于0.5s-1s
缺点:
G1
不能全方位碾压CMS
,G1
为了垃圾收集产生的相较于其他垃圾收集器额外10%-20%
的内存占用,在垃圾收集带来的额外执行负载方面也比CMS
高,运行经验表明G1
在超过6-8G
的大内存应用上具备优势,内存越大优势越大;在小于6-8G
的小内存应用上CMS
的性能表现大概率会比G1
更好
G1
相关JVM
参数
-XX:+UseG1GC
:在JDK8
或JDK7
想使用G1
需要显示配置
-XX:G1HeapRegionSize
:设置每个Region
的大小,值可以写1-32
之间的二次幂,即1、2、4、8、16、32
,单位是MB
;Region
大小设置的一般目标是按最小Java
堆划分成约2048
个区域,默认值是堆内存的1/2000
,但是如果计算得到Region
的大小小于1M
就会自动取1M
-XX:MaxGCPauseMillis
:设置期望的最大GC
暂停时间,默认是200ms
,JVM
会尽力实现,但是不能保证每次都达到;一般暂停时间几十毫秒到300ms
都是正常的;如果暂停时间设置的过小比如小于50ms
,会降低每次垃圾回收的Region
数量,增大GC
的频率提高响应速度降低吞吐量;吞吐量降低可能导致GC
的速度跟不上垃圾产生的速度导致堆被占满触发Full GC
-XX:ParallelGCThread
:G1
并行过程会出现STW
,设置并行阶段垃圾回收线程的数量,最大值为8
-XX:ConcGCThreads
:设置并发标记的线程数,一般设置为ParallelGCThread
的1/4
-XX:InitiatingHeapOccupancyPercent
:设置触发并发GC
周期的Java
堆占用率阈值,超过该阈值会触发GC
,默认值是45
即堆总大小的45%
使用G1
进行调优开发人员只需要开启G1
、设置堆的最大内存、设置最大暂停时间MaxGCPauseMillis
,剩下的都交给JVM
自动控制
垃圾收集流程
G1
的一次垃圾回收过程必须包含YGC
、老年代并发标记[该环节也会同时进行YGC
]、混合回收[Mixed GC
涉及到新生代和老年代共同回收的流程所以叫混合]三个环节;如果有需要会继续第四个串行独占高强度的Full GC
[JDK10
以前FullGC
是串行的,JDK10
提供了并行FullGC
但仍然是独占式的,且暂停时间不可控,不管是什么垃圾回收器都要尽可能避免Full GC
],Full GC
是针对GC
评估失败后提供的一种保护机制,类似于CMS
回收失败替换成Serial Old
执行垃圾回收进行兜底;正常情况下都不会出现Full GC
,出现FullGC
一般就需要进行系统的调优来避免再次出现Full GC
1️⃣YGC
:伊甸园区用尽时触发YGC
,幸存者区会跟着YGC
被动回收,G1
在YGC
阶段是一个并行独占式垃圾收集器,会暂停所有用户线程,垃圾回收线程并行对年轻代进行垃圾回收;伊甸园区仍然存活的对象存入幸存者区,幸存者区达到年龄阈值的对象移动到老年代,伊甸园区移动到幸存者区的大对象幸存者区放不下也会直接移动到老年代,YGC
在整个垃圾回收周期包括Full GC
期间都能随时因为伊甸园区满了自动触发
YGC
时G1
会停止所有用户线程的执行,G1
创建回收集Collection Set
,将年轻代伊甸园区和幸存者区的所有内存分段全部加入到回收集中等待被垃圾回收
伊甸园区和幸存者区的存活对象通过复制算法保存到空闲的region
中,该region
成为新的幸存者区,幸存者区中达到年龄阈值的对象保存到空闲列表region
或者老年代中,该空闲Region
成为老年代,原来的伊甸园区和幸存者区成为空闲区域、
YGC
的流程细节:
1️⃣扫描根:根指的是静态变量、局部变量表中的引用数据类型引用,RSet
中记录的外部引用,根作为扫描存活对象的入口
2️⃣更新RSet
:处理脏卡队列[dirty card queue
,老年代中的对象引用年轻代中的对象通过写屏障和记忆集记录老年代对象的引用不是直接实时地将老年代对象引用记录在记忆集中,因为更新RSet
需要多个线程同步,开销比较大,而是设置一个脏卡队列,当引用赋值语句执行时将属性所属对象封装成卡入队列,等到YGC
前对脏卡队列中的所有卡进行处理并更新记忆集RSet
,不在引用赋值语句处更新RSet
是为了用户线程的性能考虑]中的卡来更新RSet
,处理后的记忆集才可以真实实时反映哪些老年代对象引用了当前region
中的对象
3️⃣处理RSet
:识别仍然被老年代对象引用的新生代中的对象,这些对象被认为是存活对象
4️⃣复制对象:伊甸园区存活的对象被复制到幸存者区,幸存者区中存活的对象如果年龄没有达到阈值,年龄自增;达到年龄阈值的存活对象被复制到老年代,如果幸存者区空间不够,伊甸园区的部分对象也会直接晋升老年代
5️⃣处理引用:处理软引用、弱引用、虚引用、终结器引用、JNI
弱引用,最终让原来的region
区域置空,数据被复制到的region
成为新一轮的伊甸园区和幸存者区
2️⃣并发标记:当堆内存使用率达到45%
时触发老年代并发标记过程,期间仍然会触发YGC
流程细节
1️⃣初始标记:标记GCRoots
中的所有根节点对象,触发一次YGC
,初始标记阶段是STW
的
2️⃣根区域扫描:G1
扫描幸存者区指向老年代的引用,并标记这些引用指向的老年代对象,根区域扫描阶段不会STW
,但是必须在YGC
开始之前完成前完成根区域扫描
3️⃣并发标记:使用可达性分析算法对整个堆进行并发标记,此过程不会STW
,但是可能被YGC
中断,期间如果发现一个region
中的所有对象全是垃圾,该region
会立即被回收,并发标记过程中会计算每个region
的对象活性即区域中存活对象的比例,该对象活性用优先级队列评估回收价值最高的region
4️⃣再次标记:和CMS
一样,并发标记阶段用户线程也在执行,需要对可能漏标的对象进行修正,CMS
使用写屏障加增量更新的方式来破坏漏标的第二个条件白色对象被黑色对象再次引用无法被察觉的条件,G1
使用更快的写屏障加初始快照算法SATB
破坏漏标的第一个条件白色对象被灰色对象断开无法被察觉的条件,该阶段会STW
5️⃣独占清理:计算各region
的垃圾收集价值并根据回收价值进行排序,识别可以混合回收的区域,为混合回收阶段做准备;这个阶段不会做垃圾的收集,该阶段会STW
6️⃣并发清理阶段:识别和清理全是垃圾的region
,此时region
中还有存活对象的不会被回收
3️⃣混合回收:并发标记完成后马上开始混合回收过程,G1
规整老年代的存活对象到空闲的Region
,这些Region
也会被自动分配为老年代,该过程因为要尽可能满足指定的暂停时间只会挑选垃圾收集价值最高的几个老年代Region
进行垃圾回收,期间仍然会触发YGC
含有存活对象且垃圾占有内存空间超过65%
[可以通过JVM
参数-XX:G1MixedGCLiveThresholdPercent
进行配置,默认值为65%
,存活对象超过35%
被认为存活对象占比太高,会带来更多的不必要的复制开销且会消耗更多的时间]的老年代region
默认情况下会被分8次被回收,优先回收回收价值最高的region
,回收次数可以通过JVM
参数-XX:G1MixedGCCountTarget
设置,而且只有region
才会被回收
混合回收的回收集中包含1/8
的未被回收的老年代region
,全部的新生代region
,采用和年轻代回收一样的流程进行垃圾回收,只是多了回收已经被标记存活对象的老年代region
混合回收不一定必须要进行8
次,如果JVM
发现可以回收的垃圾占堆内存的比例低于阈值10%
,就会停止混合回收,该阈值可以通过JVM
参数-XX:G1HeapWastePercent
设置,默认值是10
,意思是允许整个堆内存有10%
的空间被浪费,避免花费很多的时间进行GC
但是回收的内存却很有限
混合回收阶段Oracle
有考虑设计成和用户线程一起并发执行,但是实现起来比较复杂,选择将该特性放到ZGC
中去实现
4️⃣Full GC
:因为Full GC
是串行独占暂停时间不可控的GC
,性能非常差且暂停时间很长,G1
的设计初衷就是避免Full GC
导致G1
进行Full GC
的原因:
YGC
前老年代的可用连续空间小于前几次年轻代晋升老年代的平均大小会直接将YGC
替换成Full GC
,方法区空间满了时[概率小,因为方法区使用的是本地内存,空间很大]
调用System.gc()
时会建议系统执行FullGC
,系统会根据运行情况自行判断是否执行Full GC
混合回收完成前老年代的空闲空间已经被耗尽,此时就会使用Full GC
暂停所有用户线程来进行兜底垃圾收集[比如暂停时间设置的太短,回收频率变高,但是如果垃圾回收的速度跟不上垃圾产生的速度内存最终还是会被耗尽并触发Full GC
]
一般正常情况下,一个最大堆内存4G
的Web
服务器,每分钟响应1500
个请求,每45
秒新分配大约2G
内存,G1
约每45
秒执行一次年轻代回收,每31
个小时整个堆的使用率达到45%
,并发标记完成后执行四到五次混合回收,仅供参考
记忆集[Remembered Set
、RSet
]:
G1
相较于其他垃圾回收器需要额外的10%-20%
的内存空间来维护一个记忆集,GC
的基础是可达性分析,但是YGC
只回收新生代中的对象,如果我们进回收新生代中的对象还要将所有对象都遍历一遍判断新生代中的对象是否是可达的[因为新生代的对象可能仅被老年代的对象引用],回收新生代不得不扫描老年代,这是非常高昂且多余的开销;在其他的分代收集器中也存在这样的问题,但是G1
不仅分代而且分Region
,而且G1
主要应用在大堆,堆越大可达性分析访问的对象就越多,无用的开销也越严重,会严重降低Minor GC
的效率
无论是在G1
还是其他分代垃圾收集器,JVM
都是通过记忆集RSet
来避免全局扫描,G1
给每个region
都配置一个RSet
,如果当前region
中的对象A
同时被两个不同region
中的对象B
和C
引用,就会将两个对象的引用地址记录在RSet
中;每次引用数据类型写操作时都会产生一个写屏障中断写操作,检查被引用的对象和当前对象是否在同一个region
中[其他垃圾收集器是检查两个对象是否一个处于年轻代,一个属于老年代],如果不在同一个region
中会将当前对象记录到被引用对象所在region
对应的RSet
记忆集的具体实现卡表CardTable
中
进行垃圾回收时,将RSet
中的引用作为GC Roots
的枚举范围,就能实现不进行全局扫描也不会出现存活对象被漏标
回收老年代的时候不需要特别关心记忆集的问题,因为回收老年代本身就要回收年轻代,回收老年代就相当于全堆扫描
注意事项:
要避免使用-Xmn
或者-XX:NewRatio
等JVM
参数显式设置年轻代的大小,固定年轻代的大小会导致期待的最大GC
暂停时间参数-XX:MaxGCPauseMillis
失效,交给JVM
自己控制就好
不要将暂停时间设置的太短,因为更短的暂停时间会导致更频繁的垃圾回收带来更多的额外如线程上下文切换的开销,导致吞吐量下降,G1
设计的吞吐量目标是90%
的用户程序执行时间和10%
的垃圾回收时间
Epsilon
JDK11
引入,无操作GC
,只做内存分配,不做垃圾回收,只适用于一次性运行完一小段程序就退出的场景
ZGC
JDK11
引入,可伸缩的低延迟垃圾回收器,侧重低延迟,ZGC
的设计目标是在对吞吐量不造成大影响的前提下实现任意堆大小情况下都将垃圾收集的暂停时间限制在10ms
内,ZGC
也基于Region
分区算法,不设置分代,使用读屏障、染色指针和内存多重映射等技术实现可并发的标记压缩算法
官方文档对ZGC
的介绍https://docs.oracle.com/en/java/javase/12/gctuning/
ZGC
工作过程的四个阶段并发标记-并发预备重分配-并发重分配-并发重映射
都是并发的,只在初始标记阶段进行STW
,停顿时间几乎就消耗在初始标记上
测试数据
在保证暂停时间不超过10ms
的条件下ZGC
的吞吐量略低于Parallel
,略高于G1
,只有1%-2%
的差距;如果不保证暂停时间不超过10ms
,ZGC
的吞吐量相较于Parallel
和G1
有将近50%
的提升
在暂停时间方面,ZGC
吊打parallel
和G1
,平均和99%
暂停时间只有1-2ms
,即使是最大暂停时间也能控制在10ms
内,而parallel
和G1
的平均暂停时间都在200-300ms
之间,G1
的平均暂停时间和parallel
差不多,但是在最差表现比parallel
差很多,G1
适合大堆场景
ZGC
的测试数据相当亮眼甚至达到革命性的要求,未来会作为服务端大内存低延迟应用的首选垃圾收集器
JDK14
中ZGC
被扩展到可以在macOS
和windows
上使用,此前只能在Linxu
上使用,在mac
或windows
上使用ZGC
需要配置JVM
参数-XX:+unlockExperimentalVMOptions -XX:+UseZGC
,前一个参数表示解锁实验性的JVM
参数
Shenandoah
RedHat
公司开发,OpenJDK12
引入,侧重低延迟,第一款不由Oracle
公司领导开发的HotSpot
垃圾收集器,商业版的OralceJDK
不愿意引入别人的垃圾收集器,Oracle
号称openJdk
和oracleJDK
没有区别,结果免费开源的openJDK
竟然功能还比oracleJDK
更多,Shenandoah
在2014
年被RedHat
捐赠给openJdk
Shenandoah
团队号称Shenandoah
暂停时间与堆大小无关,不管将堆设置成多少都有99.9%
的把握把垃圾收集的暂停时间限制在10ms
内,但是实际使用性能还是取决于堆的大小和工作负载;2016
年红帽使用ElasticSearch
对200GB
的维基百科数据进行索引并发表论文数据:暂停时间确实相较于其他垃圾收集器有质的飞跃,在其他垃圾收集器总停顿时间达到十秒左右的情况下总停顿只有320ms
,在其他垃圾收集器的最大停顿时间都是1-4
秒的情况下只有89.79ms
,平均停顿时间在其他垃圾收集器450-850ms
的情况下只有53ms
,但是没有实现停顿时间在10ms
内,在吞吐量相较于其他垃圾收集器出现了明显下降,以下是测试数据
低延迟方面很厉害,但是吞吐量也断崖式下跌,ZGC
性能上比Shenadoah
更好一点
JDK12
新特性尚硅谷视频对Shenandoah
有进一步介绍
其他厂商的垃圾收集器
AliGC
:TaobaoJVM
使用的是Ali
自己开发的AliGC
,基于G1
的算法面向大堆场景的垃圾收集器,特定场景下停顿时间比G1
要短
ZingGC
:https://www.infoq.com/articles/azul_gc_in_detail
概念简述
System.gc()
:
通过显式调用System.gc()
[里面实际调用的就是Runtime.getRuntime().gc()
]或者Runtime.getRuntime().gc()
[这个方法是一个本地方法]触发的是Full GC
,同时对堆和方法区进行垃圾回收,System.gc()
调用附带无法保证对垃圾收集器绝对调用的免责声明,经过测试事实上是否执行也是随机的
但是在调用System.gc()
以后再调用System.runFinalization()
会强制确保调用失去引用的对象的finalize()
方法
因为垃圾回收是自动的,因此一般不需要用户手动调用,但是在特殊场景下比如性能基准测试,在做测试前先做一个GC
,防止测试过程中因为内存的原因对测试结果造成影响导致结果不准确
方法中的非静态代码块中定义的局部变量出了代码块仍然存在于局部变量表中,此时GC
不会将对应的变量进行回收,按理说局部变量的作用域只在代码块内部,出了代码块局部变量表中的数据就应该被销毁;但是实际上需要后续变量复用局部变量表中失效变量的slot
空间时将对应变量值覆盖掉引用才会断掉,此时此前已经结束的代码块中定义的对象才会被垃圾回收器回收
方法弹栈也会导致局部变量表中的引用断掉,此时对象没有引用指向也可以进行垃圾回收
Java
层面的内存溢出和内存泄露:
GC
一直在发展,一般情况下不太容易发生OOM
,除非垃圾回收器的回收速度低于占用内存的增长速度
报OOM
以前一定会进行一次独占式的Full GC
JavaDoc
对内存溢出的解释:没有空闲内存,且垃圾收集器GC
后也无法提供更多的内存
没有空闲空间主要有两个原因
JVM
的堆内存设置的太小,通过JVM
参数-Xms
和-Xmx
来调整
程序中创建了大量大对象,且长时间因为被引用不能被GC回收,像JDK6
以前永久代理的字符串常量池回收不积极就容易发生永久代内存溢出,此时添加新依赖,运行时创建大量动态类,intern
方法调用太随意,都很容易发生永久代的内存溢出;永久代替换成元数据区以后这个问题得到改善
报OOM
以前一定会触发一次Full GC
清理死亡对象,尝试回收软引用指向的对象、NIO
的API
也会自动调用System.gc()
清理空间,清理以后还是内存溢出就直接抛OOM
异常
特殊情况下内存溢出JVM
能确定调用垃圾收集器不能解决问题会直接抛OOM
,不会再触发垃圾回收,比如超大对象超过堆的最大容量不会尝试进行垃圾回收而是直接抛OOM
异常
内存泄漏的解释:对象不会再被程序用到,但是GC
又不能回收的对象
实际情况中一般是因为一些不好的实践,比如像引用计算算法中的循环引用问题导致的泄露类似的情况导致的内存泄露完全是出于疏忽或者考虑不完全导致的,由比如将局部变量定义成成员变量或者类变量,将没有必要设置成会级别的数据设置成了会话级别;一般都是忘记断开引用
内存泄露的问题在于因为无法回收或者无法及时回收会慢慢增长占用的内存,直到所有内存耗尽
内存泄露举例:
单例模式:单例对象的生命周期和应用程序一样长,单例独享如果持有外部对象的引用,外部应用无法被回收从而产生内存泄露
提供close()
方法执行关闭操作的资源:像网络连接和IO通道必须手动调用close()
方法关闭,否则也会因为无法回收而导致内存泄漏
TLAB
存不下新对象重新从伊甸园区为线程分配一块新的TLAB
,旧的TLAB
的剩余空间不会再继续使用,而且单个TLAB
本身空间就比较小只占伊甸园区的1%
还要除以线程总数,很容易就会出现剩余空间无法存放新对象的情况,TLAB
的内存浪费现象比较严重,这会导致JVM
运行过程中始终有一部分内存无法被使用,为此JVM
使用最大浪费空间对TLAB
进行约束,当TLAB
剩余空间存不下新对象且剩余空间小于最大浪费空间,TLAB
所属线程会向JVM
申请一块新的TLAB
区域存储新对象,如果新TLAB
仍然存不下,对象会被直接分配到伊甸园区;如果当前TLAB
的剩余空间大于最大浪费空间,对象会被直接分配到伊甸园区;默认最大浪费空间的JVM
参数TLABRefillWastePraction
为64
,表示值为TLAB
大小的1/64
STW
:
GC
过程中会产生停顿,停顿过程中所有的用户线程都会被暂停,效果就像应用程序卡死了
为什么需要STW
:
可达性分析算法中枚举根节点需要暂停所有用户线程来保证分析前后数据的状态一致性,如果不暂停用户线程就可能导致分析过程中对象引用关系不断变化,无法保证分析结果的正确性
所有的垃圾回收器包括G1
、CMS
、ZGC
都有STW
事件,只能说随着垃圾回收器的发展,性能更优秀,停顿时间更短;以至于现在衡量一个垃圾回收器的两个指标就是吞吐量和低延迟
设置一个用户线程Thread.sleep()
每隔1毫秒打印打印一次。当发生full gc
时,打印间隔会有20-150ms
的变化
垃圾回收的并行与并发:
线程并发[concurrent]:不是多人一起走,单人就是单核,单个核交替执行多个任务导致用户以为多个任务在同时进行
并发多个任务一起相互抢占资源
线程并行[Parallel]:多个人一起走,多人即多核,真正的多个任务同时刻一起执行
并行多个任务之间不会互相抢占资源
GC
串行:单条垃圾收集线程串行工作,用户线程此时处于暂停状态,Serial
和Serial Old
是两个典型的串行垃圾回收器
GC
并行:多条垃圾收集线程并行工作,用户线程此时处于暂停状态,ParNew
、Parallel Scavenge
、Parallel Old
是三个典型的并行垃圾回收器
GC
并发:垃圾回收线程和用户线程同时执行,但不一定完全没有STW
,用户线程和垃圾回收线程也可能交替执行;CMS
和G1
是典型的并发垃圾回收器
安全点与安全区域:
安全点:用户线程只能在特定的位置才能停顿下来开始进行垃圾回收,这些位置就称为安全点,安全点就是线程指令中执行时间比较长的某些指令位置
安全点太少GC
等待的时间太长,垃圾回收不及时;安全点太频繁可能导致运行时的性能问题
大部分指令的执行时间都非常短,在执行很快的指令处停下来也会影响性能,一般会选择执行时间较长的指令位置作为安全点,比如方法调用[压入栈帧比较耗时]、循环跳转、异常跳转等
GC
时让线程停顿在安全点的方式
抢先式中断:先统一中断所有线程,某些线程不在安全点就恢复线程让线程继续执行到安全点,目前没有虚拟机采用抢先式中断
主动式中断:JVM
设置一个中断标志,每个线程运行到安全点时就主动轮询该中断标志,如果中断标志为true
,就将当前线程暂停
安全区域:
在程序执行的情况下,程序每运行很短一段时间就能遇到一个安全点,但是程序如果因为sleep
处于Blocked
状态,此时程序无法响应JVM
的暂停请求,无法去安全点进行中断,JVM
也不能等待线程被唤醒,此时可以通过安全区域解决该问题
安全区域是一段代码片段,在该代码片段中,对象的引用关系不会发生变化,因此在这段代码区域中的任何位置GC
都是安全的
运行到安全区域的线程会被标识为Safe Region
,发生GC
时,JVM
会忽略掉标识为Safe Region
的线程;当线程即将离开安全区域时会检查JVM
是否已经完成了GC
,如果已经完成了GC
会继续运行,否则线程会暂停等待直到收到可以离开安全区域的信号
引用:99%
的场景都使用的强引用,类库或者框架源码中用到了软引用和弱引用,除强引用外其他三种引用都可以在包java.lang.ref
下找到,都继承自抽象类java.lang.ref.Reference
;以下特点都是在引用关系还在的情况下,如果引用关系已经不存在了即使是强引用关联的对象也会被回收;软引用和弱引用主要用于缓存场景;虚引用用于对象回收跟踪
强引用:
概念:类似Object obj = new Object()
这种使用构造器创建一个新对象并将对象地址赋值给一个变量,这个变量就称为指向该对象的强引用;将一个强引用赋值给另一个变量对应变量也是强引用,任何情况下垃圾回收器都不会回收存在强引用关系的对象,强引用是创建对象默认的引用类型
凡是具有强引用关联的对象都是可达的,都处于可触及状态;对应的软、弱、虚引用关联的对象都对应软、弱、虚可触及状态,处于这三种状态的对象在一定条件下都可以被回收;强引用是造成Java
内存泄漏的主要原因
软引用[SoftReference
]:
存在一类对象,当内存空间足够时希望对象保留在内存中;内存空间垃圾收集后还是紧张则希望抛弃这些对象
系统将要OOM
以前,会先将不可达对象进行回收,回收后如果发现内存还是不够就会将软引用关联的对象进行回收,回收后还是没有足够内存抛出OOM
软引用通常用来实现内存敏感的缓存,高速缓存就使用的软引用,Mybatis
的源码中一些内部类就使用了软引用
软引用的创建,首先使用构造器如Object obj = new Object()
声明一个强引用关联对象,使用软引用类关联的对象的构造器如SoftReference<Object> sf = new SoftReference<Object>(obj);
创建一个软引用,然后使用obj=null
销毁强引用,不销毁强引用软引用不会对GC
造成影响;上述三行代码等价于SoftReference<Object> sf = new new SoftReference<Object>(new Object());
一行代码;通过软引用的softReference.get()
方法可以获取软引用封装的对象,如果对象已经被回收该方法返回null
软引用对象的回收可以指定一个软引用队列,通过该队列可以跟踪对象的回收情况
弱引用[WeakReference
]:
只要进行垃圾回收,只被弱引用关联的对象就会被回收
弱引用对象的创建方式WeakReference<Object> weakReference = new new WeakReference<Object>(new Object());
,也可以使用类似创建软引用的三行代码形式;也通过weakReference.get()
来获取被软引用关联的对象,该对象被回收以后,weakReference.get()
返回null
弱引用对象的回收可以指定一个弱引用队列,通过该队列可以跟踪对象的回收情况
像安卓系统的三级缓存,如果内存中存在图片就去内存中获取,内存中没有就去本地文件获取,本地文件没有就去网络中获取,内存中的图片缓存就可以使用软引用或者弱引用来关联,只在内存充足时保持缓存
集合WeakHashMap<K,V>
和弱引用相关,使用WeakHashMap
存储图片信息内存不足时就会及时地回收内存数据,WeakHashMap
中的Entry<K,V>
是WeakHashMap
的内部类,继承了WeakReference<Object>
实现了Entry<K,V>
,注意WeakHashMap
只有key
使用的是弱引用,值使用的是强引用,因此WeakHashMap
中的对象除了集合本身对key
的弱引用外,key
对应对象没有其他引用,集合会自动丢弃该键值对让键值对对应对象自动被垃圾回收
虚引用[PhantomReference
、幽灵引用、幻影引用、幻像引用]:
为一个对象设置虚引用关联的唯一目的是对象在被垃圾回收器回收时收到一个系统通知,此外虚引用不会对对象的回收造成任何影响即不会影响一个对象的生命周期,同时用户也无法通过虚引用来获取一个对象的实例,虚引用的唯一目的是跟踪对象的垃圾回收过程
虚引用通过phantomReference.get()
方法获取对象时总是获取的null
,虚引用必须和引用队列一起使用,虚引用在调用构造器创建的同时必须提供一个引用队列作为参数如PhantomReference<Object> phantomReference = new PhantomReference<Object>(obj,referenceQueue<Object>);
,用户可以自定义对referenceQueue
中引用的自定义处理逻辑
垃圾回收器回收一个对象时如果发现对象还有一个虚引用,会在回收对象后将该虚引用加入引用队列来通知用户对象的回收情况
终结器引用[FinalReference
]:
修饰词为缺省,包内可用,实际开发中一般不使用
用于实现对象的finalize()
方法,终结器的构造方法也需要传入引用队列,终结器引用关联的对象在GC
开始时,终结器引用也会入队列,由Finalizer
线程通过终结器引用找到被引用的对象并调用该对象的finalize()
方法,下次GC
时对象被回收
OopMap
GC Roots
枚举根节点的过程需要暂停用户线程,对栈进行扫描,对整个栈进行扫描很消耗性能,HotSpot
采用了空间换时间的方法,使用OopMap
存储栈上的对象引用信息,每个栈帧可能有多个OopMap
,存储在栈帧的附加信息区域,GCRoots
枚举根节点时直接通过遍历每个栈帧的OopMap
找到栈上的根节点
OopMap
中存储的数据
OopMap
记录的是栈中某个寄存器或者栈帧的某个偏移量处存储了一个对象引用,OopMap
中存储的是对象引用的位置
简述内存分配[就是对象分配策略]和回收策略、Minor GC
、Major GC
[Full GC]
对象分配基本过程
对象分配考虑内存怎么分配、在哪里分配,还要考虑对象被GC以后内存空间是否会产生内存碎片的问题
1️⃣:所有对象首先都被创建在伊甸园区,如果创建前发现伊甸园满了会触发一次YGC
[也叫作Minor GC
],通过可达性分析算法分析哪些对象成为垃圾应该被回收,没有被回收的对象转移到其中一个幸存者区中,注意JVM
为每个对象都分配了一个年龄计数器age
,将对象从伊甸园区转移到幸存者区age
会变成1
伊甸园区满了会将伊甸园区和幸存者from
区一起触发YGC
如果触发YGC
以后伊甸园区没对象了还是放不下当前对象,说明对象是超大对象会直接尝试放在老年代,老年代放不下会触发FGC
[也叫Major GC
,其实FullGC
和MajorGC
还有细微区别],老年代放得下直接分配内存,老年代如果还是放不下虚拟机会尝试自动扩容堆区空间,如果堆区已经无法扩容了则会直接抛OOM
进行YGC
也会判断幸存者区能否放下伊甸园区的对象,如果放不下伊甸园区的对象会直接晋升老年代,from
区的对象如果转移到to
区放不下也会直接晋升老年代
2️⃣:伊甸园区又满了再次触发YGC
,此次幸存的对象将会转移到另一个没有对象的幸存者to
区,同时触发上一次垃圾回收转移对象的幸存者from
区的垃圾回收,没有被回收的对象也转移到幸存者to
区,然后将to
区改为from
区,from
区改为to
区,即将空的幸存者区设置为to
区,为下一次伊甸园区YGC
做准备,每重新被转移一次幸存者区对象的age
年龄计数器就会自增1
,如果幸存者from
区中的对象的年龄计数器达到默认阈值15
,就会将年龄计数器达到15
的对象转移到老年代,年龄计数器变成16
年龄计数器的阈值可以通过JVM
参数-XX:MaxTenuringThreshold=<N>
重新设置
幸存者区满了即使年龄计数器没有达到阈值也会直接晋升到老年代
有些情况也可能对象不经过幸存者区直接从伊甸园区晋升到老年代,比如幸存者区放不下伊甸园区的对象
垃圾回收频繁收集新生代,很少收集老年代,几乎不动永久代或元空间;80%
的对象都在新生代被回收掉了
对象分配策略
所有对象优先分配到伊甸园区
大对象直接分配到老年代,因此要避免程序执行过程中创建过多的大对象
长期存活的对象会分配到老年代中
如果幸存者区中某个年龄中所有对象的内存占用超过幸存者区的一半,年龄大于等于该年龄的对象无需达到年龄计数器的阈值就可以直接进入老年代
YGC
如果幸存者区放不下对象也会被转移到老年代
使用参数-XX:HandlePromotionFailure
配置空间分配担保,伊甸园区GC
以后剩余的对象容量仍然超过幸存者区,超出的对象会直接进入老年代
辨析MinorGC
、MajorGC
和FullGC
重点要考虑MajorGC
和FullGC
,因为这两种GC
的暂停时间在MinorGC
的十倍以上,很多人在面试中是说不清楚MajorGC
和FullGC
的;大部分的时候的垃圾回收都是新生代的垃圾回收
HotSpot
的GC
按照回收区域分为部分收集和整堆收集
部分收集:不是完整收集整个堆区的垃圾收集
新生代[Eden
、S0
、S1
]垃圾收集MinorGC
/YoungGC
,MinorGC
完全等价于YGC
Minor GC
的触发机制:新生代的伊甸园区空间不足会触发MinorGC
,幸存者区满了不会触发MinorGC
,幸存者区满了会将幸存者区的所有对象都转移到老年代,MinorGC
由于Java
对象一般创建用了就会销毁因此执行非常频繁;
Minor GC
会引发STW
[即Stop The World
,暂停所有用户线程,就类似于回收垃圾的时候就别扔垃圾了,并发垃圾回收器的主要特点就是收垃圾的同时还可以制造垃圾],垃圾回收完毕后再恢复用户线程的运行,因为新生代比较小,垃圾回收的速度比较快,STW
的影响小
老年代垃圾收集MajorGC
/OldGC
,只有并发垃圾回收器CMS GC
在MajorGC
的时候才有单独收集老年代的行为,其他的垃圾回收器在进行MajorGC
的时候不是单纯只收集老年代
通常在执行MajorGC
以前会先执行一次MinorGC
,如果MinorGC
后空间还是不足才会触发MajorGC
;但是并行垃圾回收器不会执行MinorGC
而直接进行MajorGC
Major GC
的执行速度一般比Minor GC
慢10倍以上,STW
的时间也更长
如果MajorGC
以后老年代的空间还是不足就会报OOM
,因此OOM
以前一定会经过一次Full GC
混合收集MixedGC
:收集整个新生代和部分老年代的垃圾,目前只有G1 GC
才会有混合收集,主要原因是G1
回收器以region
作为单位进行垃圾回收,region
在老年代和新生代都有
整堆收集Full GC
:整个堆区和方法区一起进行垃圾收集,方法区满了也会触发Full GC
触发Full GC
的几种情况:
调用System.gc()
时会建议系统执行FullGC
,系统会根据运行情况自行判断是否执行Full GC
老年代或者方法区空间不足
从YGC
晋升老年代的对象平均大小大于老年代的可用内存
大对象直接进入老年代但是老年代的可用空间不足
Full GC
在开发中要尽量避免,让STW
的整体时间尽可能短
回收整个堆和方法区的垃圾的GC才叫Full GC
,Major GC
是回收老年代
触发FullGC
的时机
YGC
前老年代的可用连续空间小于前几次年轻代晋升老年代的平均大小会直接将YGC
替换成Full GC
,方法区空间满了时[概率小,因为方法区使用的是本地内存,空间很大]
调用System.gc()
时会建议系统执行FullGC
,系统会根据运行情况自行判断是否执行Full GC
混合回收完成前老年代的空闲空间已经被耗尽,此时就会使用Full GC
暂停所有用户线程来进行兜底垃圾收集
堆是分配对象存储的唯一选择吗?简述一下逃逸分析技术
随着JIT
编译器的发展和逃逸分析技术的逐渐成熟,栈上分配和标量替换优化技术使所有对象都分配在堆上变得不那么绝对,但是由于逃逸分析带来的标量替换实际上只是将聚合量拆分成标量存储在栈中的过程,而且JDK1.7
以后将常量池和静态变量也放在堆,因此严格意义上堆还是分配对象的唯一选择
如果一个对象经过逃逸分析后发现并没有逃逸出方法,该对象就可能被优化成栈上分配内存,无需在堆上分配内存,也无需进行垃圾回收,这也是最常见的堆外存储技术
基于OpenJDK
深度的定制的TaoBaoVM
,使用GCIH
技术可以将生命周期较长的对象分配到堆外,垃圾回收不会考虑GCIH
内部的对象,从而降低GC的频率提升GC的效率
逃逸分析技术:
逃逸分析是一种减少Java
程序中堆内存分配压力的分析算法,通过逃逸分析JVM
编译器能分析出一个新对象引用的使用范围并以此为根据决定是否要将该对象分配到堆上
如果一个对象在方法中被定义后,只在该方法内部使用,该对象被认为没有发生逃逸;一旦被外部方法引用,就认为该对象发生了逃逸;一个对象只要在作为返回值被返回,不管事实上是否被接收使用都算发生了逃逸,总之一个对象只要没有被其他方法使用的可能该对象就没有发生逃逸,就可能在虚拟机栈上分配存储,随着栈帧弹栈就一同释放掉了
发生逃逸的各种情况:
方法中创建的对象存在被外部方法调用的可能[作为方法返回值返回、创建的对象赋值给实例变量或者类变量]
方法2通过调用方法1获取的对象a
即使对象没有逃出方法2的范围仍然认为对象a
发生了逃逸
在JDK 6u23
版本后,HotSpot
默认开启了逃逸分析[可以通过参数-XX:-DoEscapeAnalysis
关闭逃逸分析],更早的版本需要使用参数-XX:+DoEscapeAnalysis
显示开启逃逸分析,可以通过JVM
参数-XX:+PrintEscapeAnalysis
查看逃逸分析的筛选结果
编译器针对逃逸分析对代码可以做出的优化
栈上分配:没有发生逃逸的对象会被首选分配到栈上,局部变量对象随着栈帧弹栈被一起回收,无需进行垃圾回收
不开启逃逸分析,创建一千万个对象需要耗费77ms
,此外堆内存较小的情况下会发生GC
;开启逃逸分析,创建一千万个对象只需要4ms
,堆空间变小了也不会发生GC
同步省略:动态编译同步块时,JIT
即时编译器可以借助逃逸分析判断同步代码块使用的锁对象是否只能被一个线程访问,如果当前对象没有发布到其他线程,JIT
即时编译器在编译同步代码块的时候会取消这部分代码的同步,避免不必要的性能消耗,提高系统并发能力,这个过程就是同步省略,也叫锁消除;JVM
自动将没必要加的锁去掉,但是在字节码文件中仍然能看到同步锁上锁和解锁的monitorenter
和monitorexit
指令
标量替换[分离对象]:标量替换实际上是为栈上分配提供基础,在Java
中一个引用数据类型实例即还可以分解成标量的数据是一个聚合量,聚合量中的基本类型实例变量就是一个标量,一个聚合量可以拆分成若干标量和子聚合量,子聚合量还可以继续拆分为多个孙标量,如果一个实例经过JIT
即时编译器逃逸分析后发现没有发生逃逸,这个原本在堆开辟空间存储的聚合量可以替换成只在栈空间开辟存储空间的若干标量,通过标量替换的方式实现栈上分配;
标量替换可以通过JVM
参数-XX:+EliminateAllocations
开启标量替换,默认就是开启的,只有开启标量替换才允许将对象大三分配到栈上
标量替换的前提是一个对象可以被拆成标量,如果不能拆比如一个没有属性的对象,还是在堆区存储
开发中方法能使用局部变量就尽量不要使用在方法外定义对象,能用局部变量就不要使用实例变量或者类变量,局部变量不仅能高效GC还能直接被分配到栈空间[分配到栈空间多线程创建对象还不需要竞争同一块堆空间]
必须在JVM
的Server
模式下才能启用逃逸分析,Server
模式需要JVM
参数-server
开启,即逃逸分析在服务端才有得,在客户端没有逃逸分析,64位操作系统上启动的默认就是Server
模式
逃逸分析的论文在1999
年就发布了,直到JDK1.6
才有实现,至今技术也不是特别成熟,根本原因是逃逸分析本身也是一个相对复杂耗时的过程也需要消耗性能,无法保证逃逸分析的性能消耗低于在堆分配存储的消耗,而且经过逃逸分析以后如果对象发生了逃逸这个逃逸分析过程就被浪费掉了,虽然不成熟但是也是即时编译器优化技术中的重要手段;对于非堆分配,淘宝的GCIH将对象放在本地内存中不考虑垃圾回收是目前比较成熟应用的方案,只要加内存就能提高系统性能;HotSpot
也没有应用直接在栈上分配对象的方案,使用的是标量替换来实现栈上分配的效果;JDK7
以后字符串常量池和静态变量也不会放在永久代和元空间上,而是直接分配到堆上;因此目前还是认为堆空间是对象实例分配的唯一空间
发生OOM的情况
OOM
对应的是OutOfMemoryError
,Throwable
下有两个子类Error
和Exception
,Exception
是狭义上的异常,Throwable
是指整个JVM
运行不正常出现"异常"情况,OOM
发生的最多的还是堆空间,一般的解决手段是通过内存映像分析工具如Eclipse Memory Analyzer
、jvisualvm
或JProfile
对堆转储快照即dump
文件进行分析,确认内存中的对象是否必要,分析到底是出现了内存泄漏[始终有引用指向某个对象,但是该对象实际上已经不使用了]还是内存溢出问题,如果dump
文件比较小就要考虑是不是程序运行期间比如NIO
用到了直接内存导致直接内存相关的区域出现OOM
问题
如果是内存泄漏,使用上述工具查看具体泄漏对象到GC Roots
的引用链,通过泄漏对象和GC Roots
引用链的信息定位出泄露代码的位置,即时把引用给断掉
如果是纯粹的内存溢出,即存活的对象都确实还必须活着,应当检查虚拟机的堆参数判断是堆还是方法区出现的OOM
,检查堆或者方法区的内存是否还可以增大,检查某些对象的生命周期是否过长[没必要静态的不要定义成静态,只在某个方法内使用的就只定义成局部变量]
虚拟机栈创建或者自动扩容时发现没有额外的内存可以创建或者扩容虚拟机栈
老年代Full GC
后发现老年代仍然放不下对象
元空间的最大内存一般设置为-1
即与系统内存保存一致,但是本身系统内存有限,或者堆内存分配过大,导致直接内存不足,从而没有足够的直接内存来装载所有的类也会直接抛出OOM
异常
不管用JProfile
还是Jvisualvm
都是无法直接监测到直接内存的占用的,想要监测可以任务管理器查看系统内存的变化
当JVM
进程使用NIO
或者其他方式操作直接内存时,开辟直接内存空间时直接内存不足,比如手动设置了直接内存上限或者系统本地内存不足,在开辟直接内存的具体代码处也会抛出OOM
异常
Java
对象创建过程
创建对象的方式
new 构造器
,私有的构造器可以通过类的静态方法来调用创建对象,比较常见的是XxxBuilder
/XxxFactory
的静态方法来调用私有构造器创建对象
class.newInstance()
:通过反射的方式,只能调用被public
修饰的空参构造器,该方法在JDK1.9
被弃用
constructor.newInstance(Xxx)
:通过反射的方式调用无参或者有参构造器,对构造器的权限修饰符没有限制,因此更加灵活,因此把Class.newInstance()
废弃了
clone()
:当前类实现Cloneable
接口,实现其中的clone()
方法,实际上Cloneable
接口是一个标识接口,不带任何方法,重写的是Object
中的clone()
方法,通过该方法可以实现一个已有对象的复制,是对对象的浅拷贝[即创建一个相同类型的新对象,如果属性值是基本数据类型新对象直接赋值老对象属性的值,如果属性值是引用数据类型新对象直接赋值老对象属性的引用地址]
反序列化
:通过反序列化可以从文件或者网络中获取一个对象的二进制流,使用二进制流还原成一个对象
第三方库如Objenesis
:使用相关字节码技术动态生成构造器对象
创建对象流程
new
指令检查目标对象类型是否已经被类加载,在堆空间开辟出对应的对象空间,根据属性类型确定对象总共占用的字节数,对所有属性值做临时初始化
new
指令会首先区检查该指令的参数能否在元空间的常量池中定位到一个类的符号引用,并检查该符号引用代表的类是否已经被类加载,如果没有进行过类加载,在双亲委派模式下使用当前类加载器以类加载器+全限定类名为key
查找对应的字节码文件,如果没有找到文件抛出ClassNotFoundException
异常,找到进行类加载生成对应的Class
对象
dup
指令将操作数栈栈顶的对象引用[这个是等待赋值的目标对象]复制一份压入操作数栈作为调用该对象相关方法的句柄
invokespecial
调用目标类型的构造器初始化对象实例
astore_1
将对象实例从操作数栈取出保存到局部变量表中
对象创建的六个步骤[以下六个步骤都执行完了才认为对象被完整创建出来了,创建对象的代码在字节码指令中被拆分出了好几行,new Object()
被拆成new
、dup
、invokespecial
,标准上是执行完构造器Object()
也就是invokespecial
对象才算完整被创建出来,但是对象的结构在new
指令执行完就被创建好了]
判断当前创建的对象对应的类是否已经被类加载,没有类加载先进行类加载
计算对象占用空间大小,在堆区为对象划分一块内存,属性中根据对应类型计算字节数,如果是引用数据类型,保存对应的对象地址占用四个字节,分配内存要考虑堆空间内存是否规整;具体采用哪种内存分配方式主要看对空间内存是否规整,是否规整由垃圾收集器是否带有压缩整理功能决定
如果堆空间内存规整,JVM
使用指针碰撞法来为对象分配内存,指针碰撞法说的高大上,实际上就是规则的内存就类似于向栈中压数据,指针始终指向栈顶,数据不停压入栈顶,JVM
如果选择基于标记整理算法的串行Serial
以及并行ParNew
即带有整理过程的垃圾收集器都会采用这种对象内存分配方式
如果堆空间内存不规则[已使用的内存和未使用的内存相互交错],JVM
需要维护一个空闲列表使用空闲列表法来为对象分配内存,空闲列表记录着哪些内存块可用哪些不可用,为对象分配内存时从列表上找到一块足够大的空间分配给对象实例并更新列表,空闲列表法对应的基于标记清楚算法的垃圾回收器比如并发CMS
[GC
以后内存不是规则的]
处理并发安全问题,多线程在堆区创建对象涉及到共享对象可能会出现线程安全问题
使用CAS
失败重试或者加锁保证更新操作的原子性
每个线程预先分配一块TLAB
,JDK8
默认是开启的,可以使用JVM
参数-XX:+/-UseTLAB
来设定
对分配内存的空间的所有属性进行默认初始化,保证对象实例字段在未赋值的情况下可以直接使用
默认初始化[零值初始化]:为指定类型变量赋默认值,变量在没有显示初始化的情况下Java
会为变量分配默认值,这个过程叫隐式初始化
显示初始化/代码块初始化:显示初始化是变量在创建时就通过赋值语句指定初始值,代码块初始化像静态代码块一样但是不需要加static
关键字,可以给实例变量赋值
构造器中初始化
设置对象的对象头信息:依次在对象头中记录对象的所属类即指向方法区的类元数据信息、对象的HashCode
,对象的GC
信息、锁信息,不同的JVM
实现具体的设置信息不同
执行init
方法初始化对象:正式调用构造器初始化成员变量并将堆内对象的首地址赋值给声明对象处的引用变量,对应着字节码指令中的invokespecial
指令,在这个步骤中会对属性进行显示初始化、代码块初始化或者构造器初始化
显示初始化、代码块初始化和构造器初始化代码对应的字节码指令都会组合到构造器的<init>
方法中
简述Java
对象结构
对象头Header
:包含运行时元数据MarkWord
和类型指针
运行时元数据markWord
:存储哈希值、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳
类型指针:指向方法区的类元数据即InstanceKlass
[JVM
创建一个InstanceKlass
结构来存储类元信息],由此可以确定对象所属类型,注意指向的是类元数据不是class
对象,注意不是所有的对象都会保存类型指针
如果是数组对象,对象头还会记录数组的长度
实例数据Instance Data
:
存储当前类代码中定义以及从父类继承下来的各种类型的字段
存放规则为先存放父类中定义的变量,再存放子类中定义的变量;相同宽度的字段总是被分配在一起;如果参数CompactFields=true
,子类的窄变量可能插入到父类变量的空隙,默认该参数就为true
字符串引用指向堆中的字符串常量池中的某个对象
对齐填充Padding
:
没有特殊含义,就是起一个占位符的作用,据弹幕说是为了补齐8字节倍数,增加CPU
的读取效率,据说和disruptor
有关,可能存在也可能不存在
JVM
如何通过对象引用访问到堆中的对象实例即对象两种访问定位方式[句柄和直接指针]
创建对象是为了使用对象中的一些功能或者对对象做指定操作,通过局部变量表中的保存对象地址的引用变量来对对象进行访问,具体访问对象的方式分为句柄访问和直接指针;JVM
规范并没有明确指出具体实现需要采用哪种方式,不同的虚拟机实现采用的具体方式不同,HotSpot VM
采用的是直接指针的方式
句柄访问:堆空间开辟了一块称为句柄池的空间,句柄池中的句柄保存了两个信息,一个是指向堆区对象实例数据的指针,一个是指向方法区对象类型数据的指针;虚拟机栈的局部变量表的引用指向句柄池中句柄的指向对象实例数据的指针,先找到句柄再通过句柄指针找到对象实例
缺点是效率比较低,而且还需要专门在堆区开辟一块空间保存对象的句柄信息
优点是虚拟机栈中的地址引用始终指向句柄池中的句柄,即使堆中的对象位置发生了改变[比如标记整理算法,幸存者区的相互转移,GC过程引起的对象转移],也只需要更改堆中句柄的对应值,而不需要修改虚拟机栈中局部变量表的地址引用,因此栈空间中的对象引用地址会非常稳定
直接指针:虚拟机栈的局部变量表中的引用直接指向堆中的对象实例数据,对象实例数据中对象头的类型指针保存着指向方法区该对象的类型数据
优点是对象访问速度快,缺点是一旦对象位置变化就需要改变虚拟机栈中相关地址引用的值,而且如果一个对象同时被多线程共享还要改很多地方,但是使用句柄访问只需要修改一处
String
类和常量池,八种基本类型的包装类和常量池
字符串常量池以前存在方法区,后来存放在堆空间
概念:
Java
通过同一份字节码文件通过不同平台上的JVM
翻译成对应平台的机器指令来实现跨平台
JVM
不和包括Java
在内的任何语言绑定,只与特定二进制文件格式的字节码文件关联,任何语言只要最后编译成正确的字节码文件就能在JVM
上运行,也是这个原因让JVM
能够成为跨语言的平台,JDK
每个版本都会发布一份java
语言规范,同时额外出一份JVM
规范,就是考虑到JVM
是一款跨语言的平台
因为JVM
都遵守JVM
规范,用户写的Java
程序编译出来的字节码文件可以在各种不同的JVM
中运行
前端编译器的任务是将符合java
语法规范的Java
代码转换成符合JVM
规范的字节码文件,javac
是JDK
自带的前端编译器,经历词法解析、语法解析、语义解析、生成字节码四个步骤可以将Java
源码编译成字节码文件
Javac
的特点是全量编译,全量编译的意思是每次编译都把Java
源码完全重新编译一次;HotSpot
没有要求必须使用Javac
来编译生成字节码,Eclipse
的前端编译器ECJ
[Eclipse Compiler for Java
]内置在Eclipse
中,是一种增量式编译器,每次Ctrl+S
时进行编译,且只会编译Java
源码中更新的内容,已经编译过的内容不会再进行编译,ECJ
的编译速度比javac
快,编译质量和Javac
是差不多的,这也是用Eclipse
启动项目比IDEA
快的原因;同时Tomcat
也使用的是ECJ
来编译JSP
文件,ECJ
基于GPLv2
开源协议开源,在Eclipse
官网可以下载ECJ
的源码
此外还可以使用AspectJ
编译器替代Javac
,但是需要自行去下载并进行配置
前端编译器不会对代码进行优化,即使使用不同的前端编译器也不会对代码性能造成差异,代码优化主要由JIT
编译器负责
字节码文件是源代码经过前端编译器编译生成的二进制类文件,字节码的内容就是JVM
指令,C
和C++
是直接通过编译器生成机器码指令,任何一个字节码文件都对应唯一一个类或者接口的定义,字节码文件是一组以八位字节为基础单位的二进制流,字节码文件也不一定以磁盘文件的形式存在,也可以是网络中的二进制流数据,字节码文件本质上就是二进制流数据
字节码文件没有任何分隔符号,字节码文件中的字节顺序、字节数量、字节的含义都需要严格限定,不用分隔符号是为了压缩字节码文件的大小
字节码文件由表或者无符号数构成,无符号数就是特定长度[u1
表示当前无符号数占1个字节,u2
表示占用两个字节]表示特定含义的二进制码;表的长度不确定,表的前面会使用无符号数表示表的长度,表中存放常量池、当前类实现的所有接口、字段、方法、属性等信息;整个字节码文件相当于一个表,其中的每个无符号数或者表相当于字节码文件这张大表中的每个元素,每个元素的含义、次序、占用字节长度都是限定或者显示指明的,很像通信协议
字节码指令:字节码指令由一个字节长度的操作码和其后此操作需要的零个或多个操作数构成,很多操作码都不需要操作数,操作码和操作数之间使用空格分隔
IDEA
会反编译字节码文件,但是因为编译时不会编译注释信息,因此反编译无法获取源文件中的注释信息
解读Class
文件的方式
1️⃣:在IDEA
中安装jclasslib
插件,将java
源码进行编译,将光标放在目标类上,View
--Show Bytecode With Jclasslib
,Jclasslib
也有独立的客户端,安装了jclasslib
客户端字节码文件可以自动使用jclasslib
打开
2️⃣:使用Notepad++
打开字节码文件和EditPlus
打开效果是一样的,但是Notepad++
可以安装插件HEX-Editor
,在Nodepad++
中插件--HEX-Editor--View in HEX会将乱码的二进制类文件展示为16进制码,用户需要自己分析每个字节代表什么操作码,操作码后面跟的是什么操作数
3️⃣:打开软件Binary Viewer
,直接把字节码文件拖进该软件,就能看到和Notepad++
一样的16
进制码,只是信息量比Notepad++
大一些,比如十六进制码对应的二进制或者十进制码是多少
4️⃣:使用JDK
自带的javap
工具可以反解析字节码文件,使用命令javap -v 字节码文件名.class > 文件名.txt
将字节码文件的反解析结果输出到txt
文件中
类文件结构
官方文档:https://docs.oracle.com/javase/specs/jvm/se8/html/jvms-4.html
,这也是JVM
规范中第四章的内容
字节码文件结构随着JDK
迭代会有调整,比如指令的添加或者移除,但是基本框架和结构非常稳定,因为要考虑向下兼容的问题
魔数
u4
表示用四个字节表示魔数,魔数作为字节码文件的标识,字节码文件通过魔数十六进制的cafebabe
来识别,如果字节码文件的模数不为cafebabe
会报错ClassFormatError
字节码文件版本
版本依次包含minor_version
和major_version
,各占两个字节,分别表示小版本/副版本和大版本/主版本,主版本.副版本
构成了字节码文件的格式版本号,这个版本号和JDK
的大版本有对应关系,45.3
对应JDK1.1
,此后JDK
每升级一个大版本,主版本从45
加1
分别为45
、47
...[每个主版本减去44
就是JDK
的版本],副版本均为0
高版本的JVM
可以执行有低版本编译器生成的字节码文件,低版本JVM
不能执行高版本编译器生成的字节码文件即高版本的JVM
只能解释运行低版本的字节码文件,否则报错java.lang.UnsupportedClassVersionError
,开发环境和生产环境使用的JDK
版本可能不同,要实现JVM
对编译后的字节码文件版本向下兼容,避免出现该问题
常量池
常量池包含两部分,两个字节的常量池长度constant_pool_count
也叫常量池计数器,常量池constant_pool
表cp_info
;如果常量池的长度为10
,实际常量池表的长度为9
,索引0
没有分配数据
常量池是字节码文件中内容最丰富的区域,常量池中存储着字节码解析时字节码文件中存储字段名、方法名、方法的返回值类型、方法的形参;常量池是字节码文件的基石,随着JDK
的迭代,常量池中的数据也会进行调整
常量池表用于存放编译时期生成的各种字面量和符号引用,常量池被类加载以后存放在方法区的运行时常量池中
常量池计数器表示常量池表项的数量、实际上常量池表项的数量为常量池计数器值减1即constant_pool_count-1
,之所以少一个是将索引为0
的位置空出来,目的是为了在属性、方法、字段引用常量池中的索引时在特殊场景下就不引用任何一个常量池表项,此时就通过引用索引值0
来表示
常量池主要存放字面量和符号引用两大类常量
字面量:字面量包含文本字符串[就是字符串]和声明为final
的常量值[比如final int NUM = 10
]
符号引用:符号引用主要包含三种情况,类和接口的全限定名、字段的名称和描述符[描述符指字段的类型]、方法的名称和描述符[描述符指方法的形参和返回值类型],符号引用可能引用符号引用,但是符号引用最终指向的是字符串字面值
全类名指比如com.earl.commom.Product
,全限定名指比如com/earl/common/Product
简单名称指不包含类型和修饰符的方法名或者字段名,就是方法名和字段名
描述符指描述字段的数据类型、方法的参数列表[包含数量、类型和次序]、返回值类型
基本数据类型和void
都使用大写字符表示,引用数据类型统一用L
加对象的全限定名来表示;一维数组用[
表示,二维数组用[[
表示
标识符 | 数据类型 |
---|---|
B | byte |
C | char |
D | double |
F | float |
I | int |
J | long |
S | short |
Z | boolean |
V | void |
L | 引用数据类型,比如Ljava/lang/Object; 为了表示区分使用分号结尾 |
[ | 一维数组double[][][] 三维double 数组对应[[[D |
动态链接是指虚拟机运行时,从常量池中获取符号引用加载到运行时常量池,在类加载过程的解析阶段将符号引用图换位直接引用并且指向具体内存地址的过程
符号引用:使用一组唯一的符号[就是类和接口的全限定名、字段方法的名称和描述符]来描述被引用的目标,符号引用与虚拟机的实现的内存布局无关,引用的目标也不一定已经加载到内存当中
直接引用:直接引用可以是指向目标的指针、相对偏移量或者一个能简介定位到目标的句柄;如果一个符号引用被替换成了直接引用,那说明被引用的目标肯定已经存在于内存中了
标识15
、16
、18
是JDK7
引入的,体现Java
对动态语言的支持;常量池表项可能是下面14
种类型中的任意一个,常量池表项的顺序不是按下表顺序,每一个表项都会通过标识位表示当前表项的类型
每个常量池表项的第一个字节都是标识位tag
,除了字符串每个常量池表项的字节数都是固定的,符号引用通过常量池的索引指向其他符号引用或者字符串字面值,但是最终还是指向字符串字面值
常量池表中只会出现int
、float
、long
、double
四种基本数据类型的常量,常量类型为byte
、short
、char
、boolean
都是使用int
类型来装配的
编译器字符串字面量的长度是不确定的,编译后才确定字符串字面量的长度,因此字符串需要额外使用一个字段表示字符串的长度
常量池可以理解为字节码文件的资源仓库,一个类的所有类型、方法、属性的名字和描述都在常量池,后续很多数据类型以及字节码的非常量池结构都会指向字节码的常量池,是字节码文件空间占用最大的数据项,Java
编译时JVM
还没有启动,选择在JVM
加载字节码的时候进行动态链接,字节码文件不会保存方法、字段的最终内存布局信息、因此只能使用符号引用来表示方法和字段,在运行期通过类加载和动态链接将符号引用转换得到真正的内存入口地址
类型 | 标识 | 描述 |
---|---|---|
CONSTANT_utf8_info | 1 | UTF-8 编码的字符串这个就是使用改进过的 UTF-8 编码的字符串除了字符串标识还会使用两个字节记录字符串长度 一个字节代表一个字符 |
CONSTANT_integer_info | 3 | 整型字面量 |
CONSTANT_Float_info | 4 | 浮点型字面量 |
CONSTANT_Long_info | 5 | 长整型字面量 |
CONSTANT_Double_info | 6 | 双精度浮点型字面量 |
CONSTANT_Class_info | 7 | 类或接口的符号引用 一个字节的标识位 两个字节指向常量池中类对应全限定名的字符串字面值的索引 |
CONSTANT_String_info | 8 | 字符串类型字面量 |
CONSTANT_Fieldref_info | 9 | 字段的符号引用 第一个单字节标识符 第二个双字节指向字段所在类的符号引用 CONSTANT_Class_info 在常量池表中的索引 |
CONSTANT_Methodref_info | 10 | 类中方法的符号引用,包含一个字节的标识符 两个字节指向方法所在类的符号引用 CONSTANT_Class_info 在常量池表中的索引两个字节指向方法名和类型的符号引用 CONSTANT_NameAndType_info 在常量池表中的索引 |
CONSTANT_InterfaceMethodref_info | 11 | 接口中方法的符号引用 |
CONSTANT_NameAndType_info | 12 | 字段或方法名的符号引用 第一个是标识符 第二个是方法或者变量名字的对应字符串字面值在常量池表的索引 第三个是字段或者方法的形参类型列表和方法返回值类型对应字符串字面值在常量池表的索引 |
CONSTANT_MethodHandle_info | 15 | 表示方法句柄 |
CONSTANT_MethodType_info | 16 | 标识方法类型 |
CONSTANT_InvokeDynamic_info | 18 | 表示一个动态方法调用点 |
常量池表项细节
一个字符串常量池示例
第一个CONSTANT_Methodref_info
的内容为0X0a00040012
,
其中0004
就是所在类的符号引用为索引为4
的常量池表项即CONSTANT_Class_info
,内容为0x070015
,其中0015
指向常量池中索引为21
的字符串字面量CONSTANT_utf8_info
,内容为0x0100106a617661216c616e672f4f626a656374
,对应的字符串为java/lang/Object
;
0012
是指向名称和类型的描述符CONSTANT_NameAndType_info
在常量池表项中的索引12
,内容为0x0c00070008
,其中0007
表示名称字符串字面量即CONSTANT_utf8_info
在常量池中的索引为7
,内容为0x0100063c696e
,对应字符串为<init>
;0008
表示该方法的返回值名称和形参列表字符串字面量即CONSTANT_utf8_info
在常量池表中的索引为8
,内容为0x010003282956
,对应字符串为()V
,其中()
中是形参列表,V
是返回值类型,只是此处没有形参也没有返回值
第二个CONSTANT_Fieldref_info
的内容为0x0900030013
,
其中0003
是字段所在类为索引为3
的常量池表项CONSTANT_Class_info
,内容为0x070014
,0014
指向常量池表索引为20
的字符串字面量,内容为0x010016636f6d2f617467756967752f6a617661312f44656d6f
,对应字符串为com/atguigu/java1/Demo
0013
是指字段对应的名字和类型是索引为19
的常量池表项CONSTANT_NameAndType_info
,内容为0x0c00050006
,其中0005
表示字段名在字符串常量池中索引为5
的对应字符串字面值,内容为0x0100036e756d
,对应字符串为num
;0006
表示字段对应类型在字符串常量池中索引为6
的对应字符串字面值,内容为01000149
,对应字符串为I
,表示当前字段的类型为基本数据类型int
[示例代码]
xxxxxxxxxx
package io.renren.test;
public class Test {
private int num = 1;
public int add() {
num = num + 2;
return num;
}
}
[字节码文件]
[常量池表项]
[常量池表项1]
类名对应常量池表项4
,对应的字符串字面值为java/lang/Object
;
方法的名字和形参类型列表、返回值类型对应常量池表项18
,18
又指向7
和8
,对应的字符串字面值为<init>:()V
访问标志access_flags
[访问标志、访问标记]
两个字节,表明当前字节码是一个类还是一个接口,类或者接口的权限修饰符是什么[是否被定义为public
、abstract
或者final
]
只要是一个类[非接口]都会带ACC_SUPER
访问标识,使用ACC_SUPER
可以让类更准确地通过super.method()
定位父类的方法,现代编译器都会使用该标识
注解除了设置ACC_ANNOTATION
外也需要设置ACC_INTERFACE
访问标识
两个字节的访问标识是由所有的标志名称对应标志值做16
进制加法得到的,比如0x0021
是由0x0001
和0x0020
相加得到的,即说明类被public
修饰
有ACC_INTERFACE
标识表明当前是一个接口或者注解,没有说明是一个类;设置了ACC_INTERFACE
同时必须设置ACC_ABSTRACT
;同时不能设置ACC_FINAL
、ACC_SUPER
、ACC_ENUM
没有设置ACC_INTERFACE
的字节码文件除了不能设置ACC_ANNOTATION
可以设置其他任何访问标志
ACC_ENUM
标志表明当前类或者其父类为枚举类型
Java
中一个类只能被权限修饰符public
或者缺省修饰,但是内部类可以被全部四种权限修饰符修饰
public
:内部类可以被任何其他类访问
protected
:内部类可以被同一包中的类以及所有子类访问
无修饰符(默认,也称包级私有):内部类只能被同一包中的类访问
private
:内部类只能被其外部类访问
类索引
当前类的全限定类名,两个字节,指向的是常量池中对应值的索引的常量池表项中,是一个CONSTANT_Class_info
,指向字符串字面值就是对应类的全限定名
父类索引
当前类的父类的全限定类型,两个字节,指向常量池中对应值的索引的常量池表项中,是一个CONSTANT_Class_info
,指向字符串字面值就是当前类的父类的全限定名
Object
的字节码对应父类索引位置值为0000
,表示Object
没有父类
接口索引集合
包含两部分,当前类实现的接口数量interfaces_count
,占两个字节;
实现的接口表,接口表竟然也是u2
占两个字节,这里u2
应该是错的,老师说是一个表,表中的每一个表项都是常量池中的一个CONSTANT_Class_info
,源码中实现接口的顺序和表中表项的顺序是一致的,表的索引i
从0
开始,0<=i<interfaces_count
字段表集合
包含两部分,字段数量fileds_count
,占两个字节,表示当前字节码文件字段表的字段个数,也叫字段计数器;字段表fields
,类型是field_info
表
字段表包含类变量和实例变量,不包括代码块内部或者方法内部声明的局部变量;字段的名字和类型编译前无法确定,只能通过引用常量池中的常量来描述;字段表指向常量池中的索引,描述每个字段的标识符、访问权限修饰符、是类变量还是实例变量、是否是常量
字段表集合不会列出从父类或者实现的接口中继承来的字段,但是可能列出原本Java
代码中不存在的字段;比如内部类为了保持对外部类的访问性,会自动添加指向外部类实例的字段
Java
中不支持字段的重载,一个类中的字段不能重名[类型和权限不同仍然不能重名],但是同一个字段表中两个字段的名字相同但是描述符不一致[权限修饰符或者字段类型不同],字段表仍然认为这两个字段合法
每个字段表项都是一个field_info
结构,field_info
结构如下
访问标志:访问标志和类的访问标志一样也是由几个修饰符对应标志值求和得到,可选的标志名称如下;访问标志由两个字节构成
字段名索引:两个字节,指向常量池中对应字段名的字符串字面值在常量池中的索引,内容就是字段的名字
字段描述符索引:两个字节,字段描述符的作用是描述字段的数据类型,指向常量池中对应数据类型的字符串字面值在常量池中的索引,内容就是字段的类型,注意这个字段描述符直接就指向字符串字面值,而不是指向常量池中的CONSTANT_NameAndType_info
或者CONSTANT_Fieldref_info
字段的属性计数器:两个字节,表示属性集合中的元素数量,属性集合中的每个元素都是一种字段属性,不同字段属性的结构不同,以常量的常量属性为例,结构为
attribute_name_index
是属性名的索引,指向常量池中字符串字面值的索引,常量属性的属性名索引对应字符串的内容是ConstantValue
;attribute_length
是属性长度,常量字段的属性长度恒为2
;constantvalue_index
是常量字段的值的索引,对应常量池中对应数据类型的字面量的索引,比如常量是int
类型,这里constantvalue_index
就是常量池中其中一个CONSTANT_Integer_info
的索引
除了字段有属性信息,方法和当前类都有属性表记录其属性信息,这个属性和字段是两回事,注意区分
xxxxxxxxxx
ConstantValue_attribute{
u2 attribute_name_index;
u4 attribute_length;
u2 constantvalue_index;
}
方法表集合
包含两部分,方法表的长度methods_count
即方法计数器,占两个字节,方法计数器;方法表methods
,类型是method_info
表
方法的数量除了类本身定义的方法还有额外构造方法<init>
和<clinit>
方法
methods
中的每个表项都指向常量池中CONSTANT_Methodref_info
,每一个方法表项都包含方法的访问权限修饰符信息,方法的返回值类型和方法的参数信息,如果方法不是本地方法,方法表项中也会有放方法代码对应的字节码指令
方法表中只包含当前类或者接口中声明的方法,不包含从父类或者父接口继承来的方法;此外方法表中还可能出现由编译器自动添加的方法,典型的就是编译器产生的类或接口的<clinit>
初始化方法和实例的初始化<init>()
方法
Java
语言要求方法的重载要求同一个类中两个同名方法具有不同的特征签名,特征签名就是一个方法的形参在常量池中字段符号引用的集合,方法的返回值类型并不包含在特征签名中,这意味着Java
语言方法重载必须形参列表不一样;但是在字节码文件中,只要两个方法的描述符不完全一致这两个方法就是合法共存的,这意味着,两个方法有相同的名称和特征签名即形参列表完全相同,但是只要方法返回值类型不同,这两个同一个类的形参列表相同返回值不同的同名方法也是合法的
方法表项method_info
结构,和字段表项field_info
的结构基本上是一样的
访问标志:两个字节,同样由下面一个或者多个标志值求和得到
方法名索引:两个字节,保存指向字符串常量池中字符串字面值的索引,字符串内容就是方法名
描述符索引:两个字节,指向常量池中保存当前方法的形参列表和返回值类型信息的字符串字面值的索引,而不是指向常量池中的CONSTANT_NameAndType_info
或者CONSTANT_Methodref_info
属性计数器:两个字节,表示属性表项的个数
属性集合:方法代码对应的字节码指令保存在属性表项中、此外属性表项还会保存方法的异常信息、LineNumberTable
以及LocalVariableTable
等属性信息
方法表的Code
属性的格式[方法表的属性集合中有Code
属性]
属性名索引:两个字节的属性名索引,对应字符串为Code
属性长度:四个字节,当前属性除了属性名索引和属性长度后续内容占用的字节数
操作数栈的最大深度:两个字节
局部变量表的存续空间:就是局部变量表的长度,两个字节
字节码指令的长度:四个字节,表示方法代码对应字节码指令占用的字节数
字节码指令:每个操作码占一个字节,每个字节码指令对应操作码可以通过jclasslib
直接跳转官方文档查询;操作数如果是符号引用占用两个字节保存的是常量池表项索引,注意如果是invokespecial
的操作数,操作数是一个方法的符号引用CONSTANT_Methodref_info
;putfield
指令的操作数是一个属性的符号引用CONSTANT_Fieldref_info
,在字节码文件中占用两个字节,还行啊,十个字节能表示八条字节码指令
异常表长度:两个字节,方法没有异常该长度为0
异常表:
属性表计数器:Code
属性中还可以有属性表、两个字节表示Code
属性的属性表中元素的个数
属性表:
Code
属性的属性表中第一个属性的属性名为LineNumberTable
,第二个属性的属性名为LocalVariableTable
[LineNumberTable
指令代码行号映射表的格式]:指明字节码指令偏移量与Java
源程序代码的对应关系
两个字节的属性名索引attribute_name_index;
四个字节的属性长度attribute_length
,指当前属性不包含属性名索引和属性长度的剩余字节数
两个字节的line_number_table_length
,表示有几个大括号表示的结构体
两个字节的start_pc
和两个字节的line_number
,记录的是字节码指令行号和Java
源码行号的对应关系[两个大括号结构体分别表示方法的字节码指令开始行号、结束行号与Java
代码行号的对应关系],字节码的行号专业术语称为偏移量
xxxxxxxxxx
LineNumberTable_attribute{
u2 attribute_name_index;
u4 attribute_length;
u2 line_number_table_length;
{
u2 start_pc;
u2 line_number;
} line_number_table[line_number_table_length];
}
[LocalVariableTable
的格式]
两个字节的属性名索引attribute_name_index;
四个字节的属性长度attribute_length
,指当前属性不包含属性名索引和属性长度的剩余字节数
两个字节的local_variable_table_length
,表示有几个大括号表示的结构体,就是表示有几个局部变量
两个字节的start_pc
指局部变量声明的字节码指令行数
两个字节的length
表示从start_pc
行号开始,局部变量的作用域长度
两个字节的name_index
表示局部变量的名字对应字符串字面值在常量池中的索引
两个字节的descriptor_index
表示局部变量的类型对应字符串字面值在常量池中的索引
两个字节的index
表示当前局部变量在局部变量表中所在槽位的索引
xxxxxxxxxx
LocalVariableTable_attribute{
u2 attribute_name_index;
u4 attribute_length;
u2 local_variable_table_length;
{
u2 start_pc;
u2 length;
u2 name_index;
u2 descriptor_index;
u2 index;
} local_variable_table[local_variable_table_length];
}
属性表集合
包含两部分,属性表的长度attributes_count
即属性计数器,占两个字节,表示属性表中属性表项的个数;属性表attributes
,类型是attribute_info
表,每个属性表项都是一个attribute_info
结构
上面的字段就是类的属性,这个属性是指字段的属性,比如当前类的类名、方法字节码的LineNumberTable
和LocalVariableTable
等信息;字段的属性用于存储额外的信息,比如初始化值[初始化值只有常量才有]、注释信息等;以常量字段的常量属性信息为例,常量属性的结构为
xxxxxxxxxx
ConstantValue_attribute{
u2 attribute_name_index;
u4 attribute_length;
u2 constantvalue_index;
}
整个字节码结构的最后一个结构是一个属性表,在字节码的其他结构比如字段表和方法表的表项也可能是一个属性表,字节码文件这个大表结构中的表项属性表存放的是字节码文件携带的辅助信息,比如class
字节码文件对应源文件的名称、类上是否带有RetentionPolicy.Class
或者RetentionPolicy.RUNTIME
表明生命周期的注解,这些信息一般用于JVM
的验证、运行和Java
程序的调试,不需要太关注
字段表和方法表也有自己的属性表,存储常量的属性信息、方法的字节码指令、异常信息等;属性表项没有严格的顺序,设置只要属性表项的名字不同用户可以向属性表项中写入自定义的属性信息,但是JVM
运行时如果发现不认识的属性表项会自动忽略
attribute_info
结构通用格式:
JDK8
中一共有23
种属性,不同属性的结构不一样,属性的通用格式为
属性名索引两个字节指向常量池中的字符串字面值,比如方法表的Code
属性,
属性长度刻画的是属性表项除了属性名索引和属性长度外占用的字节数
认为所有的属性都含有属性名、属性长度和属性表三个结构
类型 | 名称 | 数量 | 含义 |
---|---|---|---|
u2 | attribute_name_index | 1 | 属性名索引 |
u4 | attribute_length | 1 | 属性长度 |
u1 | info | attribute_length | 属性表 |
常见属性格式[属性的结构要通过属性名去JVM
规范的第4.7
章]
SourceFile
的格式
两个字节的属性名索引对应的常量池字符串字面值为SourceFile
四个字节的属性长度,这个值官方文档固定要求就是2
源码文件索引:两个字节指向常量池中的字符串字面值,对应的内容为带后缀名的源文件比如Demo.java
类型 | 名称 | 数量 | 含义 |
---|---|---|---|
u2 | attribute_name_index | 1 | 属性名索引 |
u4 | attribute_length | 1 | 属性长度 |
u2 | sourcefile_index | 1 | 源码文件索引 |
Integer x=5
和int y=5
有什么区别
Integer
和int
使用==
判断是否相等会将Integer
自动拆箱拆成基本数据类型和int
类型的值进行比较
[Integer x=5
字节码指令]
向操作数栈压入数据5
调用Integer.valueOf
静态方法,传参就是操作数栈中的5
,获取Integer
对象
将Integer
对象存入局部变量表索引为1
的位置
xxxxxxxxxx
0 iconst_5
1 invokestatic #2 <java/lang/Integer.valueOf : (I)Ljava/lang/Integer;>
4 astore_1
[Integer.valueOf
方法]
IntegerCache
是Integer
中的静态内部类,IntegerCache
中有一个Integer
数组类型的常量,数组长度为127-(-128)+1=256
,然后从int
类型的-128
开始自增,循环向数组中调用Integer
的单参int
构造器创建Integer
对象从索引0
开始存入数组中
如果valueOf
方法的入参在-128-127
之间,直接从integerCache
的入参+128
索引处获取Integer
对象;如果入参不在这个范围内就使用Integer
的单参构造方法实例化Integer
对象
xxxxxxxxxx
public static Integer valueOf(int i) {
if (i >= IntegerCache.low && i <= IntegerCache.high)
return IntegerCache.cache[i + (-IntegerCache.low)];
return new Integer(i);
}
[int x=5
的字节码]
向操作数栈压入5
将5
存入局部变量表中索引为2
的位置
xxxxxxxxxx
5 iconst_5
6 istore_2
分析如下代码的结果[父类构造方法调用不会显式初始化以及属性根据引用类型的静态绑定]
[代码]
xxxxxxxxxx
class Father {
int x = 10;
public Father() {
print();
x = 20;
}
public void print() {
System.out.println("Father.x = " + x);
}
}
class Son extends Father {
int x = 30;
public Son() {
print();
x = 40;
}
public void print() {
System.out.println("Son.x = " + x);
}
}
public class SonTest {
public static void main(String[] args) {
Father father = new Son();
System.out.println(father.x);
}
}
[执行结果]
这个结果光从代码层面上来解释是不行的,就是把书翻烂了也很难解释,需要从字节码层面分析,弹幕说这是静态分派和动态分派,周的那本书上有
xxxxxxxxxx
Son.x = 0
Son.x = 30
20
[分析流程1]
在Father
的构造器调用之前,成员变量x
进行了默认初始化0
和显式初始化10
,构造器中初始化20
,在构造器初始化前调用了print()
方法打印被显示初始化成员变量的值10
成员变量的赋值过程:默认初始化0
--显式初始化/代码块中初始化--构造器中初始化--对象实例化以后通过对象.属性
或对象.方法
的方式对成员变量进行赋值
构造器初始化完成后属性被赋值20
,此时father.x
就是20
xxxxxxxxxx
class Father {
int x = 10;
public Father() {
print();
x = 20;
}
public void print() {
System.out.println("Father.x = " + x);
}
}
class Son extends Father {
int x = 30;
public Son() {
print();
x = 40;
}
public void print() {
System.out.println("Son.x = " + x);
}
}
public class SonTest {
public static void main(String[] args) {
Father father = new Father();
System.out.println(father.x);
}
}
[执行结果]
xxxxxxxxxx
Father.x = 10
20
[Father
构造器字节码]
首先调用父类Object
的构造器
然后进行Father
中成员变量的显式初始化
显示初始化以后执行Father
的构造方法开始调用print
方法,然后在构造器赋值x=20
xxxxxxxxxx
0 aload_0
1 invokespecial #1 <java/lang/Object.<init> : ()V>
4 aload_0
5 bipush 10
7 putfield #2 <io/renren/test/Father.x : I>
10 aload_0
11 invokevirtual #3 <io/renren/test/Father.print : ()V>
14 aload_0
15 bipush 20
17 putfield #2 <io/renren/test/Father.x : I>
20 return
[分析流程2]
xxxxxxxxxx
class Father {
int x = 10;
public Father() {
print();
x = 20;
}
public void print() {
System.out.println("Father.x = " + x);
}
}
class Son extends Father {
int x = 30;
public Son() {
print();
x = 40;
}
public void print() {
System.out.println("Son.x = " + x);
}
}
public class SonTest {
public static void main(String[] args) {
Father father = new Son();
System.out.println(father.x);
}
}
[执行结果]
xxxxxxxxxx
Son.x = 0
Son.x = 30
20
[Son
构造器字节码]
调用Son
的构造器首先会调用父类Father
的构造器,调用Father
的构造器,注意此时调用父类构造器时父类构造器执行前不会像直接调用父类构造器一样会在构造器执行前先使用父类定义的成员变量对成员变量显式初始化,此时只有子类的成员变量被默认初始化,然后直接调用父类的构造方法只会执行父类构造方法里的代码,父类构造方法执行完子类成员变量再执行子类的构造方法,此时子类构造方法本身执行以前会利用子类的成员变量定义信息对子类成员变量进行显式初始化;一句话子类成员变量的显示初始化在父类构造器调用后在当前类构造器调用前执行,父类构造器执行时不会进行显示初始化
父类构造器执行前不会进行显式初始化,因此在进行父类构造器赋值前先打印,此时打印的是默认初始化的值0
父类构造器执行结束后此时子类成员变量被父类构造器赋值20
,然后子类构造器被调用,子类构造器执行前先进行当前类成员变量的显式初始化赋值30
,然后执行子类构造器先打印成员变量的值30
,然后进行子类构造器初始化40
为什么最后打印实例化后的子类成员变量的值是20
而不是40
呢,这是因为大多数面向对象的编程语言中,属性的访问是静态绑定而不是动态绑定,编译阶段编译器会根据引用的类型而非对象的实际类型来决定访问引用类型对应的属性值,这意味着如下示例
静态绑定指编译时确定被调用的代码、动态绑定指运行时根据对象的实际类型来调用代码
xxxxxxxxxx
class Animal {
String name = "Animal";
}
class Dog extends Animal {
String name = "Dog";
}
public class Main {
public static void main(String[] args) {
Animal animal = new Dog();
System.out.println(animal.name); //"Animal"
System.out.println(((Dog)animal).name);//"Dog"
}
}
xxxxxxxxxx
0 aload_0
1 invokespecial #1 <io/renren/test/Father.<init> : ()V>
4 aload_0
5 bipush 30
7 putfield #2 <io/renren/test/Son.x : I>
10 aload_0
11 invokevirtual #3 <io/renren/test/Son.print : ()V>
14 aload_0
15 bipush 40
17 putfield #2 <io/renren/test/Son.x : I>
20 return
Javap
工具
概述:javap
是jdk
自带的字节码文件解析工具,作用是根据字节码文件解析出当前字节码文件的版本号、访问标志、常量池表、方法表的code
属性、局部变量表、异常表、代码行偏移量映射表等信息;不显示当前类索引、父类索引,接口索引集合等结构;<clinit>()
和<init>()
被反编译成对应的静态代码块和构造器
通过局部变量表,用户可以查看局部变量的作用范围、所在slot
槽位信息以及槽位复用的情况
注意使用javac xx.java
编译源码生成的字节码中不会包含局部变量表的信息;如果使用javac -g xx.java
编译才会在字节码文件中生成局部变量表信息[使用javap
指令反解析也会少局部变量表信息];默认情况下,Eclipse
、IDEA
编译时默认会生成局部变量表、指令和代码行偏移量映射表等信息
javac -g xx.java
相较于javac xx.java
就是每个方法的Code
属性中都多了一个LocalVariableTable
属性表,此外常量池中多了几个常量池表项比如字符串LocalVariableTable
,建议手动编译的时候加上参数-g
指令语法:javap <options> <classes>
,其中<options>
表示javap
命令可以使用一些参数,<classes>
表示要反解析的字节码文件的名字。
可设置的<options>
参数,多个参数可以组合一起使用比如javap -s -p Demo.class
,字节码文件不带class
后缀也是可以的
-version
:显示当前javap
所在jdk
的版本信息,不是指当前字节码文件是在哪个jdk
版本下编译生成的
-public
:仅显示当前字节码文件中修饰符为public
的公共字段、构造器和方法
-protected
:仅显示当前字节码文件中修饰符为public
和protected
的字段、构造器和方法
-p
/-private
:显示当前字节码文件中所有的字段、构造器和方法以及静态代码块,不包含非静态代码块
-package
:显示当前字节码文件中所有非私有的字段、构造器和方法以及静态代码块,不包含非静态代码块
-sysinfo
:显示当前字节码文件的系统信息,比如字节码文件的绝对路径、最近的修改时间、占用字节大小、MD5散列值、源文件的文件名,此外还会显示当前字节码文件中所有非私有的字段、构造器和方法以及静态代码块,不包含非静态代码块
-constants
:显示常量的最终值,前面展示常量只展示结构不展示值,该参数也会显示当前字节码文件中所有非私有的字段、构造器和方法以及静态代码块,不包含非静态代码块;同时常量还会带上值
-s
:显示当前字节码文件中所有非私有的字段、构造器和方法以及静态代码块,不包含非静态代码块;同时常量还会带上值;同时显示字段或者方法的描述符[即类型和形参类型列表]
-l
:显示当前字节码文件中所有非私有的字段、构造器和方法以及静态代码块,不包含非静态代码块;方法会带上行号映射表和局部变量表;如果编译后的字节码文件中不包含局部变量表,该指令不会显示局部变量表
-c
:显示当前字节码文件中所有非私有的字段、构造器和方法以及静态代码块,不包含非静态代码块;同时显示方法的Code
属性即方法对应的字节码指令,只想看字节码指令使用-c
-v
/-verbose
:显示字节码文件的版本信息,访问标识,常量池、字段和方法的描述符信息、方法的Code
属性,代码行号映射表、局部变量表等;这显示的是字节码文件最全的信息,注意不包含私有的字段、构造器和方法,需要包含私有字段和方法可以使用组合指令javap -v -p Demo.class
静态代码块在前端编译器编译时会转换成<clinit>
方法存储在字节码文件中,使用javap
解析的时候会被自动将<clinit>
标识为static{}
一个方法的执行会涉及到虚拟机栈中的局部变量表和操作数栈、堆中对象、常量池、帧数据区[栈帧除了局部变量表和操作数栈的方法返回地址、动态链接和一些附加信息]和方法区
字节码指令
官方字节码指令文档https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-6.html
概述:
JVM
的指令由一个字节长度、代表特定操作的字节码和其后的零至多个操作需要的参数即操作数构成,一些简单的操作数被融入到操作码一体比如a
,因为操作码的长度为一个字节,因此操作码总数不会超过256
条,实际上字节码指令总共约200
多条
字节码的执行模型:
1️⃣:自动让PC
寄存器的值加1
2️⃣:根据PC
寄存器的指示位置从字节码流中取出操作码
3️⃣:如果取出的操作码存在操作数,从字节码流中依次取出对应数量的操作数
4️⃣:执行操作码定义的操作
5️⃣:如果字节码流还有数据循环1️⃣-4️⃣直到字节码流长度为0
一些指令与数据类型强相关,比如aload_0
其中的a
就表示引用数据类型,0
表示局部变量表中索引为0
的位置;iconst_1
其中的i
表示int
类型,数据类型在字节码指令中对应的字符i-int
、l-long
、s-short
、b-byte
、c-char
、f-float
、d-double
一些字节码指令的助记符没有明确指明操作数据类型,比如arraylength
,但是要求操作数只能为一个数组类型,即没有指定操作码的字节码指令也隐含操作数的类型
byte
、char
、short
、boolean
不被指令支持,byte
、short
类型数据会在编译期或者运行期将byte
和short
转换成带符号扩展的int
类型[就是有正有负的int
类型,因为byte
和short
也可能有正有负],将boolean
和char
转换成零位扩展的int
类型数据[就是只有正的int
类型];对应着局部变量表中的一个Slot
槽对应四个字节;处理byte
、char
、short
、boolean
数组也会转成int
数组来进行处理
指令可以从局部变量表、常量池、堆中对象、方法调用、系统调用中获取值数据或者对象引用数据并压入操作数栈,也可以从操作数栈中取一个到多个值完成赋值、加减乘除、方法传参和系统调用等操作
指令带load
、push
、const
、ldc
的都是将数据压栈到操作数栈中;指令带store
都是将操作数栈中的数据保存在局部变量表中
加载与存储指令[使用频率最高]:这类指令的作用就是将数据在常量池、局部变量表和操作数栈之间来回传递
局部变量压栈到操作数栈
1️⃣:xload
,这个指令需要操作数,向操作数栈中压入局部变量表索引为操作数的数据,比如iload 0
和iload_0
效果是一样的,其中x
可选的值为i
、l
、f
、d
、a
;
2️⃣:xload_<n>
,其中x
可选的值为i
、l
、f
、d
、a
;n
的可选值为0-3
;这种指令不需要显示声明操作数,实际上操作数已经隐含在指令中了,意思是从局部变量表中索引为n
的位置取出数据压入操作数栈中,n
设置为0-3
是因为这几个指令使用的比较频繁,字节码指令占用空间少[增加了指令数量,减少了字节码的体积],超出0-3
的范围还是要使用xload
指令显式声明索引
32位JVM
局部变量表中引用数据类型占4
个字节;64
位JVM
默认会在堆内存小于32G
时启用压缩指针,引用大小为4
个字节,如果显示禁用,引用大小为8
个字节;如果堆内存大于32G
,引用大小为8
个字节
字节码解析的局部变量表中被复用的slot
可能不会显示,但是Misc
中的Maximum local variables
会显示所有局部变量的个数
常量压栈到操作数栈:将常量压入操作数栈,根据数据类型分为const
、push
、ldc
系列指令
1️⃣:const
系列
指令格式 | 功能 | 备注 |
---|---|---|
iconst_<i> | 将int 类型常量压入操作数栈 | i∈[-1,5] 0 ...5 指的是数据,不是索引值为 -1 的时候指令为iconst_m1 |
lconst_<l> | 将long 类型常量压入操作数栈 | l∈[0,1] ,指数据 |
fconst_<f> | 将float 类型常量压入操作数栈 | f∈[0,2] ,指数据 |
dconst_<d> | 将double 类型常量压入操作数栈 | d∈[0,1] ,指数据 |
aconst_null | 将null 压入操作数栈 |
2️⃣:push
系列:当int
类型数据超过[-1,5]
的范围时需要使用bipush
和sipush
来向操作数栈压入int
类型数据
bipush
:将范围在[-128,127]
的int
类型数据压入操作数栈,bipush
只能接收8位整数作为操作数[b
可以认为byte
表示一个字节]
sipush
:将范围在[-32768,32767]
的int
类型数据压入操作数栈,sipush
只能接收16位整数作为操作数[s
可以认为short
表示两个字节]
3️⃣:ldc
系列:如果int
类型数据范围超过[-32768,32767]
,可以使用ldc
指令
ldc
:ldc
指令接收一个8
位参数,该参数指向常量池中的int
、long
、double
、float
、String
的索引,JVM
会根据索引找到数据并压入操作数栈;long
、double
只要不是0
和1
,float
不是0
、1
、2
,这三种数据类型就会使用ldc
指令向操作数栈压入数据[操作数栈中存放字符串是存放的字符串对象在堆内存中的引用];此外ldc
的操作数还可以是字符串对象的索引[类型不同值相同的常量在常量池中的符号引用不同]
ldc_w
:向操作数栈中压入int
或float
类型数据时,如果常量池中的目标常量索引太大,会自动切换ldc_w
指令,支持16
位参数;如果索引比较小都会使用ldc
指令,jvm
会根据索引找到常量值压入操作数栈
ldc_2w
:向操作数栈压入double
类型数据时,只要不是0
或者1
,需要使用常量池中的数据,会直接使用ldc_2w
指令,jvm
会根据索引找到常量值压入操作数栈
操作数栈数据弹栈装入局部变量表
1️⃣:xstore
:其中x
的可选值为i
、l
、f
、d
、a
,需要提供一个byte
类型的操作数指定局部变量表存储数据的槽的索引,注意float
占用四个字节对应局部变量表中的一个槽
2️⃣:xstore_n
:其中x
的可选值为i
、l
、f
、d
、a
;其中n
的可选值为0-3
的整数,表示局部变量表的槽slot
的索引;指令的功能是从操作数栈中弹出指定类型的数据保存到局部变量表中索引为n
的槽
处理局部变量表访问索引的指令[没讲]
算术指令
对操作数栈上的两个变量值做特定运算,再将结果重新压入到操作数栈,大多数算术指令都是操作的两个变量值,只有像类似取反这种只操作单个变量值
凡是涉及到byte
、short
、char
、boolean
类型及其数组类型的数据运算,都储存为int
类型使用int
类型的指令来处理
运算时溢出:两个很大的正整数相加结果可能是一个负数,但是JVM
并没有对溢出数据进行处理,只规定了除法和求余指令出现除数为0
抛出ArithmeticException
异常,注意不论short
和char
的值是多少,加法运算结果总是int
类型,其他的运算方式没测;如果是两个int
类型相加,运算溢出确实会得到负的结果
运算模式:
向最接近的数舍入模式:浮点数计算时,就是浮点数要四舍五入到指定的精度,如果精度的下一位正好是5
且是最后一位,则摄入到最接近的偶数,这叫四舍六入五考虑,Java
使用的向最近接的数舍入模式就使用的是四舍六入五考虑的模式
向零舍入模式:浮点数转换为整数时都是直接从小数点截断取小数点前面的整数
NaN
值:算术操作的结果没有明确数学定义也会使用NaN
来表示并且返回NaN
使用int
类型除以0
会抛ArithmeticException
异常,但是使用double j = 10/0.0;
[其中10
是int
类型]会显示Infinity
表示无穷大;使用double d = 0.0 / 0.0
会得到结果NaN
,如果两个int
类型的数据相加溢出可能会变成负数
1️⃣:加法指令xadd
,其中x
可以是i
、l
、f
、d
2️⃣:减法指令xsub
,其中x
可以是i
、l
、f
、d
3️⃣:乘法指令xmul
,其中x
可以是i
、l
、f
、d
4️⃣:除法指令xdiv
,其中x
可以是i
、l
、f
、d
5️⃣:求余指令xrem
,其中x
可以是i
、l
、f
、d
[取余单词对应remainder
]
6️⃣:取反指令xneg
,其中x
可以是i
、l
、f
、d
[取反单词对应negation
]
整数-1
从二进制上来说全是1
,使用-1
和一个整数做位异或运算可以将一个数取反
7️⃣:自增指令iinc
i += 10
运算符会使用自增指令如iinc 2 by 10
,如果i
为byte
类型使用byte = i+10
会编译报错,但是使用i+=10
就不会报错
注意iinc 1 by 1
是将局部变量表中索引为1
的变量值加1
,不是操作操作数栈中的变量
int
类型比如i++
会使用iinc
指令,但是double
类型或者是short
类型比如d++
则是加载d
到操作数栈,指定常量1
,两者相加再存入局部变量表;short
类型即使使用int
替换也不会使用iinc
指令,因此实际开发中建议这种i++
自增的优先选择int
类型
只有局部变量的i++
和i--
才会使用iinc
指令,如果是成员变量即使数据类型是int
也不会使用iinc
指令而是压入数据和常量1
使用xadd
或者xsub
来进行自增或者自减操作,iinc
指令只会对局部变量表中的int
类型才会生效
位运算指令
1️⃣:位移指令:ishl
、ishr
、iushr
、lshl
、lshr
、lushr
该指令有两个操作数,第一个操作数是要进行移动的数据,第二个操作数是要移动的位数,以上指令分别对应左移、右移、无符号右移[无符号右移是不保留符号位将所有位向右移动,在左侧填充0
,溢出取剩余部分]
2️⃣:位或指令:ior
、lor
3️⃣:位与指令:iand
、land
4️⃣:位异或指令:ixor
、lxor
比较运算指令[比较栈顶两个元素的大小,并将比较的结果存放在操作数栈中]
1️⃣:dcmpg
、dcmpl
、fcmpg
、fcmpl
、lcmp
d
、f
、l
分别针对double
、float
、long
类型数据
cmpg
/cmpl
:两个指令的功能都是弹出操作数栈栈顶的两个元素依次为V1
,V2
;如果v1==v2
压入0
,如果v1>v2
压入-1
;如果v1<v2
压入1
;只有像double
和float
类型数据因为NaN
的存在,如果遇到NaN
,压入操作数栈的结果可能不同,如果指令是fcmpg
遇到NaN
会压入1
,如果是fcmpl
遇到NaN
会压入-1
;
long
型整数没有NaN
值,所以不需要准备两套指令
比较指令使用一般会结合控制转移指令一起使用
类型转换指令
类型转换指令可以将两种的不同数值类型相互转换[基本数据类型除了boolean
类型以外都是数值类型],类型转换指令分为宽化类型转换和窄化类型转换,宽化指int
转换为float
;float
转换为int
会称为窄化类型转换,类型转换操作一般用于实现用户代码中的显示类型转换操作,或者用来处理字节码指令中数据类型相关指令无法与数据类型一一对应的问题
1️⃣宽化类型转换:指数据类型从小范围类型向大范围类型转换,这种转换不需要在Java
代码中进行强转操作,自动通过编译生成对应类型转换的字节码指令,助记int ---> long ---> float ---> double
前后顺序不变两两排列,如long l=i
转换得到的新值会重新写入到局部变量表的新槽中,int
类型转换成long
类型新存入局部变量表的数据会占用两个槽,转换前只占用一个槽;
int
类型转换成long
、float
、double
类型对应的字节码指令依次为i2l
、i2f
、i2d
long
类型转换成float
、double
类型对应的字节码指令依次为l2f
、l2d
float
类型转换成double
类型对应的字节码指令为f2d
🔎:正常从int
转换为long
,int
转化为double
不会发生精度损失,转换前后的值精确相等;但是从int
、long
转换到float
,或者long
转换为double
可能会发生精度损失,可能丢失掉最低有效位上的值,转换后的浮点数值根据IEEE754
按照向最接近舍入模式java
中对应四舍六入五考虑的方式得到正确整数值;这是因为long
类型占8
个字节,float
类型占4
个字节,float
一部分表示底数,另一部分表示指数,因此可以存储的范围更大,但是会导致数据的精度不够高;Java
中涉及到高精度数据需要使用BigDecimal
,精度损失比如下例,数据从123123123
变成了123123120
发生了精度损失,当数据比较小的时候不一定会发生精度损失,比如例test2
;同时注意这种转换永远不会抛出运行时异常
xxxxxxxxxx
public void test1(){
int i = 123123123;
float f = i;
System.out.println(f);//1.2312312E8
}
public void test2(){
long i = 123123123123L;
double d = i;
System.out.println(d);//1.2312312E11
}
注意byte
、char
、short
类型数据的转换也同样是全部当做int
类型处理并使用int
转换的相关指令,这三种类型本身就会全部当做int
处理;对这三种数据类型的处理主要是考虑到节省指令数量,因为不愿意让操作码占用字节数超过一个字节,同时后续可能还有新的指令加入,比如JDK7
就新加入了invokedynamic
指令,因此最大限制256
的基础上还要有一些空余;同时局部变量表中的槽固定为32
位,为了内存对齐直接将这三种数据类型当做int
处理
2️⃣窄化类型转换:也叫强制类型转换,在Java
代码中需要强转符,指数据类型从大范围类型向小范围类型转换;
int
类型转换成byte
、short
、char
类型对应的字节码指令依次为i2b
、i2c
、i2s
long
类型转换成int
类型对应的字节码指令依次为l2i
float
类型转换成int
或long
类型对应的字节码指令依次为f2i
、f2l
double
类型转换成int
、long
或float
类型对应的字节码指令依次为d2i
、d2l
、d2f
其他类型转换成byte
、short
、char
类型会使用两条字节码指令先转换成int
类型,再转换成byte
、short
、char
类型
如果是short
类型转成byte
类型会直接当做int
类型转成byte
类型
🔎:容量大向容量小转换很容易出现精度损失问题,可能会导致转换结果具备不同正负号、不同数量级,但是窄化类型转换不会导致JVM
抛出运行时异常
🔎:如果一个浮点值窄化转换为整数类型int
或者long
,如果浮点值是NaN
,转换结果为0
;浮点数不是无穷大按照IEEE754
向零舍入模式取整,如果获取的整数在int
或者long
的可表示范围内,转换结果就是该整数,如果在int
或者long
的可表示范围外,就转换为int
或者long
可表示的最大或者最小范围
🔎:如果将一个double
类型转换成一个float
类型,如果原数据绝对值太小以至于无法使用float
来表示,将返回float
类型为正负零;如果原数据绝对值太大以至于无法用float
来表示,将返回float
类型的正负无穷大Infinity
;如果double
类型是NaN
,将转换成float
类型的NaN
;可以通过double d = Double.NaN
将double
类型变量声明为NaN
,通过double d1 = Double.POSITIVE_INFINITY
声明成double
类型的正无穷大,通过double negativeInfinity = Double.NEGATIVE_INFINITY;
声明double
类型的负无穷大
对象创建与访问指令
Java
的对象是针对基于类或接口创建的实例、基于数组创建的实例
1️⃣对象创建指令:[因为Java
创建对象的频率很高,因此这部分指令的使用频率也很高]
new
:创建类实例的指令,接收一个指向常量池索引的操作数,指明创建对象对应类型,执行完以后将对象引用压入操作数栈
在堆开辟空间,类进行初始化并创建实例,该指令执行结束后会将对象地址引用存入操作数栈,创建类实例以后会执行dup
指令在操作数栈将对象地址引用复制一份并保存在操作数栈中;然后调用类的构造器方法执行实例初始化此时栈顶的第一个对象地址引用弹出供方法调用根据对象地址找到对象;然后将对象引用存入局部变量表中
注意有参构造器通过invokespecial
指令执行类实例初始化方法时会同时弹栈构造器形参和对象地址引用
newarray
:创建基本类型数组
byte
、short
、char
都会创建int
类型数组
操作数是数组长度,一般数组长度都会先入操作数栈,然后随着创建数组的字节码指令执行被弹栈
anewarray
:创建引用类型数组
操作数为类在常量池中的索引
multianewarray
:创建多维数组
操作数为多维数组类型在常量池中的索引
注意new String[10][]
通过字节码指令可以看到只会调用anewarray
创建一个数组对象,数组对象的每个元素为String[]
,只是每个元素还没有初始化都是null
,并不是直接通过多维数组指令创建这种只指定了数组元素个数没有指定每个元素的String
个数的多维数组;如果是new String[10][5]
此时就会先压栈数组两个数组长度然后弹栈调用multianewarray
2️⃣字段访问指令:通过对象访问指令可以获取对象实例或者数组实例中的字段或者数组元素
访问类变量
getstatic
:把静态变量压入操作数栈中
比如System.out.println("hello")
对应字节码
getstatic #8 <java/lang/System.out>
:#8
字段符号引用存储的信息为System
类中out
字段,字段对应的类型是Ljava/io/printStream;
,调用该指令会将静态变量System.out
的引用地址压栈到操作数栈;ldc #9
是压入操作数栈常量池中的字符串字面值hello
;invokevirtual
调用常量池中方法的引用java/io/PrintStream.println
,调用该方法时会从操作数栈弹栈out
字段的引用地址和字符串字面值供方法调用使用
xxxxxxxxxx
0 getstatic #8 <java/lang/System.out>
3 ldc #9 <hello>
5 invokevirtual #10 <java/io/PrintStream.println>
8 return
putstatic
:把静态变量从操作数栈中弹出
该指令接受一个常量池属性符号引用,从操作数栈弹栈属性值,将属性值放入类结构的name
属性[因为属性值赋值给类变量,因此不需要操作数栈压栈再弹栈对象地址]
getfield
:把实例变量压入操作数栈中
该指令接受一个常量池属性符号引用,去堆中获取对应属性并将属性值或者属性对象压栈到操作数栈
putfield
:把实例变量从操作数栈中弹出
该指令接受一个常量池属性符号引用,从操作数栈弹栈属性值和属性地址,将属性值放入堆中对象的对应属性值中
3️⃣数组操作指令:
xastore
:将操作数栈的元素值、数组元素的索引以及数组引用弹栈,将元素值存储到堆中数组对象的对应索引处,x
的可选值为b
、c
、s
、i
、l
、f
、a
,b
同时表示对byte
和boolean
的操作,c
表示对char
数组的操作,s
表示对short
的操作
xaload
:从操作数栈中依次弹栈数组元素索引和数组引用,根据这两个参数将一个数组元素值压栈到操作数栈中,x
的可选值为b
、c
、s
、i
、l
、f
、a
,b
同时表示对byte
和boolean
的操作,c
表示对char
数组的操作,s
表示对short
的操作
将操作数栈中的值存储到byte
和boolean
数组元素中都使用baload
指令
arraylength
:弹出栈顶的数组引用,根据引用获取数组长度,将数组长度压入操作数栈
4️⃣类型检查指令:
instanceof
指令:在使用多态如A a = new Dog()
的时候,我们可能需要将父类型引用强转为子类型引用来调用子类特有的方法或者子类的属性;在进行强转前一般会用关键字instanceof
来判断当前引用指向的对象是否是指定类型的实例,如果是就进行强转;instanceof
指令的操作数为常量池中类型的符号引用,执行完后会将判断结果压栈到操作数栈
checkcast
指令:该指令执行强制类型转换,同时会检查类型强制转换是否可以进行,如果可以进行,该指令不会改变操作数栈,否则会抛出ClassCastException
;一般调用instanceof
判断过强转就能够进行
方法调用与返回指令[实际代码中方法的调用频率也非常高]
1️⃣invokevirtual
:调用对象的实例方法,根据对象的实际类型来调用方法,支持多态,只要方法有可能被子类重写
如果对象被强转成接口类型,再调用对应的实例方法,此时会使用invokeinterface
指令,比如thread.run()
会使用invokevirtual
,但是((Runnable)thread).run()
会使用指令invokespecial
2️⃣invokeinterface
:通过接口类型引用调用子实现实例的方法,比如B
实现了接口A
,通过A a = new B()
,通过a.方法名()
调用方法时会使用invokeinterface
指令
3️⃣invokespecial
:special
指进行特殊处理的实例方法,包括三种特殊方法,实例初始化方法[构造器]、私有方法、父类方法[父类方法调用会从当前类依次向上找其父类、父类的父类直到找到对应方法体],这些方法的特征是不可以被重写,这些方法都是通过静态类型绑定的,不会在调用时进行动态派发
静态类型绑定:指代码编译阶段编译器就能根据变量的声明类型确定调用的函数或者变量的地址,不考虑运行时变量的实际类型,运行时因为不需要额外的查找或者解析,因此执行效率比较高;缺点是灵活性低,无法实现真正的多态
动态类型绑定:也称为晚期绑定、运行时绑定或者多态[虚方法分派],指在运行时根据对象的实际类型来调用方法而不是变量的声明类型,动态类型绑定是Java
实现多态使用父类类型的引用指向子类对象并调用子类重写的父类方法的关键机制和基础,允许子类覆盖父类的方法并在运行时动态地调用子类的版本;动态类型绑定在运行时确定调用的具体方法;动态类型绑定适用于除静态方法、私有方法、final
修饰方法、实例构造器、通过super
调用父类方法外的所有方法即虚方法;动态类型绑定因为运行时需要查找方法表因此执行效率相较于静态类型绑定略低
4️⃣invokestatic
:调用类中的静态方法,静态方法也通过静态类型绑定,因为静态方法本身不会被子类重写
注意私有静态方法还是会使用invokestatic
指令
调用接口的静态方法还是会使用invokestatic
指令
5️⃣invokedynamic
:这个没说,老师让自己了解
6️⃣方法返回指令
return
:方法返回值类型为Void
的方法、实例初始化方法、类和接口的类初始化方法对应指令均为return
ireturn
:当返回值类型为int
、short
、char
、byte
、boolean
类型时对应指令为ireturn
ireturn
方法会将当前方法操作数栈顶部的元素弹出,将这个元素压入方法调用者的操作数栈中,然后丢弃当前方法的整个栈帧,恢复调用者栈帧的执行
xreturn
:x
的可选值为l
、f
、d
、a
🔎:System.out.println(a+b)
会先执行a+b
的字节码指令,然后将a+b
的结果存储到操作数栈中,然后调用System.out.println()
方法时会生成新的栈帧并将a+b
的和作为参数传入新栈帧的局部变量表
🔎:如果当前方法是被synchronized
修饰的方法,方法返回指令执行前还会执行一个隐含的monitorexit
指令释放锁
🔎:返回值类型和实际返回的值不同可能会自动进行宽化类型转换,比如
xxxxxxxxxx
public float returnFloat(){
int i = 10;
return i ;
}
操作数栈管理指令:提供像操作普通栈一样操作操作数栈的指令
pop
:弹出操作数栈栈顶的元素,被弹出的数据直接废弃
注意操作数栈的基本单元是Slot
槽,一般方法执行结束操作数栈还有数据会使用该指令
pop2
:弹出操作数栈栈顶的两个元素,被弹出的数据直接废弃
弹出两个元素或者弹出一个占两个Slot
的数据类型
dup
:复制操作数栈栈顶数据一个Slot
并压栈操作数栈栈顶,该指令调用比较多的是创建对象时复制引用地址供构造器进行初始化
dup2
:复制操作数栈栈顶数据两个Slot
并压栈操作数栈栈顶
dup_x1
:将操作数栈栈顶一个Slot
复制并插入到栈顶两个Slot
的下面
dup_x2
:将操作数栈栈顶一个Slot
复制并插入到栈顶三个Slot
的下面
dup2_x1
:将操作数栈栈顶两个Slot
复制并插入到栈顶三个Slot
的下面
dup2_x2
:将操作数栈栈顶两个Slot
复制并插入到栈顶四个Slot
的下面
swap
:交换操作数栈栈顶的两个Slot
JVM
没有提供交换两个64
位数据类型的指令
nop
:该指令的字节码为0x00
,和汇编中的nop
一样表示什么都不做,该指令一般用于调试占位
控制转移指令:对应Java
中分支结构和循环结构的字节码指令
比较运算指令[比较栈顶两个元素的大小,并将比较的结果存放在操作数栈中]
1️⃣:dcmpg
、dcmpl
、fcmpg
、fcmpl
、lcmp
d
、f
、l
分别针对double
、float
、long
类型数据
cmpg
/cmpl
:两个指令的功能都是弹出操作数栈栈顶的两个元素依次为V1
,V2
;如果v1==v2
压入0
,如果v1>v2
压入-1
;如果v1<v2
压入1
;只有像double
和float
类型数据因为NaN
的存在,如果遇到NaN
,压入操作数栈的结果可能不同,如果指令是fcmpg
遇到NaN
会压入1
,如果是fcmpl
遇到NaN
会压入-1
;
long
型整数没有NaN
值,所以不需要准备两套指令
比较指令使用一般会结合控制转移指令一起使用
条件跳转指令
1️⃣:ifeq
、iflt
、ifle
、ifne
、ifgt
、ifge
、ifnull
、ifnonnull
;
这些指令接受两个字节的操作数用于计算指令跳转的位置,整体上功能为弹出栈顶元素,如果操作数栈栈顶的int
类型数值满足某一条件跳转到给定位置;一般这些指令会和比较运算指令结合使用,通过比较运算指令先比较两个double
、float
、long
类型数据的大小并根据比较结果向操作数栈压入int
类型的-1
、0
、1
;然后根据Java
代码中使用的比较运算符确定使用上述哪种条件跳转指令比较int
类型的-1
、0
、1
和0
的大小来决定后续跳转执行的代码
如果是一个int
类型一个上述三种类型会先将int
类型通过宽化类型转换转成long
、float
、double
类型然后执行上述过程
如果是两个int
类型比较大小跳转分支会使用比较跳转指令
如果是int
类型和0
比较大小会直接使用条件跳转指令
指令 | 功能 |
---|---|
ifeq | 当栈顶int 类型数值等于0 时跳转 |
ifne | 当栈顶int 类型数值不等于0 时跳转 |
iflt | 当栈顶int 类型数值小于0 时跳转 |
ifle | 当栈顶int 类型数值小于等于0 时跳转 |
ifgt | 当栈顶int 类型数值大于0 时跳转 |
ifge | 当栈顶int 类型数值大于等于0 时跳转 |
ifnull | 当栈顶数值等于null 时跳转 |
ifnonnull | 当栈顶数值不等于null 时跳转 |
两个float
比较大小会先将两个数压栈到操作数栈,然后调用比较运算指令比较两个数的大小,根据比较结果向操作数栈中压栈-1
、0
、1
。根据原来比较运算符决定使用哪种条件跳转指令和0
比大小,满足条件跳转到指定偏移量的字节码指令执行后续指令,如果不满足则继续向下执行并通过返回指令放弃后续指令的执行
System.out.println()
有一系列重载方法,如果打印的结果是一个boolean
类型,会调用参数为布尔类型的println(boolean)
方法,boolean
类型在JVM
常量池中用Z
表示,在字节码层面直接弹栈操作数栈中的int
类型的1
表示true
,0
表示false
比较条件指令
1️⃣:if_icmpeq
、if_icmpne
、if_icmplt
、if_icmpgt
、if_icmple
、if_icmpge
、if_acmpeq
、if_acmpne
功能:如果预设条件成立就进行跳转,否则执行下一条指令
以字符i
开头的指令针对int
类型整数操作[byte
、short
也当做int
处理],以a
开头的指令表示对对象引用的比较
以上指令均指针栈顶两个Slot
类型的数值,前者指后一个弹栈的数值
数值压栈时第一个数就是前者,第二个数是后者,对java
代码中的比较运算符取反,然后比较结果再取反就是实际的结果
指令 | 功能 |
---|---|
if_icmpeq | 前者等于后者时跳转 |
if_icmpne | 前者不等于后者时跳转 |
if_icmplt | 前者小于后者时跳转 |
if_icmpgt | 前者小于等于后者时跳转 |
if_icmple | 前者大于后者时跳转 |
if_icmpge | 前者大于等于后者时跳转 |
if_acmpeq | 两引用类型数值相等时跳转 |
if_acmpne | 两引用类型数值不相等时跳转 |
多条件分支跳转指令:多条件分支跳转指令专为switch-case
语句设计,此外给定值不匹配任何case
值会跳转default
1️⃣tableswitch
:用于case
值连续的switch
条件跳转[这里的连续指类似1234
这种可以直接通过索引找到匹配的值],内部只存放起始值和终止值和若干跳转偏移量,通过给定的操作数index
可以直接定位到跳转偏移量位置,效率比较高
2️⃣lookupswitch
:内部存放着离散的case-offset
对,在前端编译时会对case-offset
针对case
值进行排序,这也是一种优化;运行的时候效率会提高一些,每次执行都要搜索全部的case-offset
对,找到匹配的case
值,根据对应偏移量计算跳转地址,效率较低
如果有一个case
分支没有break
语句会直接执行下一个case
分支,遇到break
通过对应字节码goto
跳转,即此时会执行两个或多个case
分支
jdk7
引入了switch
的新特性,case
值可以是String
类型,jdk5
的新特性switch
引入了枚举类型;如果case
值是字符串类型,在生成case-offset
对时case
值为字符串的,然后根据哈希值严格升序;根据给定字符串的哈希值去匹配对应的偏移量,然后调用String
的equals
方法去验证两个字符串是否相等;这种方式比每个字符串都去调用equals
方法来判断效率高
无条件跳转指令
1️⃣goto
:接收两个字节的操作数,直接跳转到指定偏移量的字节码,可以向前跳转也可以向后跳转
2️⃣goto_w
:偏移量太大超过双字节的带符号整数的范围可以使用goto_w
,接收四个字节的操作数,可以表示更大的地址范围,可以向前跳转也可以向后跳转
🔎:while
和for
循环都是通过无条件跳转指令来实现的,并没有那么多实际代码,while
循环就是一个条件判断指令和一个无条件跳转指令搭配;for
循环也只是一个比较运算指令、一个条件跳转指令和一个无条件跳转指令组合实现的
for
循环内部[包括括号中]定义的i++
不可以在for
循环外部使用
do{i++}while(i<=100)
至少会执行一次代码块中的i++
操作
异常处理指令
异常处理过程:
异常对象的生成:异常可以通过用户手动调用throw
抛出,出现许多系统自定义好的运行时异常时JVM
检测到异常状况会自动抛出
这些运行时异常不会出现在异常表中也不会生成对应的athrow
指令
异常的处理:通过try-catch-finally
抓抛模型处理,早期JVM
处理异常通过字节码指令jsr
、ret
指令来实现[已废弃],现在使用异常表来进行处理
如果方法通过throws
抛出异常,会在字节码文件中的方法表中生成对应方法的Exceptions
属性会记录方法可能抛出的所有异常;throw
手动抛出通过指令athrow
实现
异常表:只要一个方法定义了try-catch
或者try-finally
[没有catch
块会在finally
对应字节码执行完毕后执行athrow
手动将异常抛给上层调用方法]结构或者throw
抛出异常,就会创建异常表,异常表保存了每个异常的处理信息和finally
块信息,异常表的信息包括异常捕获范围的字节码偏移量起始位置、结束位置、程序计数器记录的异常处理的字节码偏移地址、被捕获的异常类在常量池中的索引
异常表Exception table
在方法表的Code
属性中
异常也存在多态,只要是异常表记录的异常类的子实现类就行,也会根据异常表的信息跳转到对应的异常处理字节码指令
发生异常后根据异常表找到对应异常的catch
块对应的字节码偏移量地址,将异常对象保存到操作数栈;然后将异常对象存入局部变量表的索引为0
处,如果是实例方法会存放在索引为1
处;然后执行catch
块中的代码比如又将异常对象加载到操作数栈中,调用对应异常对象的实例方法printStackTrace()
打印异常堆栈信息;执行完catch
语句如果没有finally
块直接通过无条件跳转指令goto
跳转到return
结束方法执行
说的简单点就是根据异常类型去异常表找对应catch
的字节码地址直接跳转执行,没有finally
直接通过无条件跳转指令跳转返回指令return
,有finally
就跳转finally
对应字节码指令执行后再执行return
一个异常被抛出时,JVM
会在当前方法里找一个匹配的异常处理,如果没有找到,当前方法会强制结束并弹栈当前栈帧并将异常重新抛给上层调用的方法栈帧,如果所有栈帧弹出前仍然没有找到合适的异常处理,当前线程将被终止;如果异常在最后一个非守护线程抛出,会导致JVM
终止
实例
返回值是hello
,按理说执行return
前会先执行finally
将字符串重新赋值atguigu
,但事实并非如此;经过字节码分析,在返回前确实先执行了finally
,也确实将局部变量表中原来应该返回的变量修改了,但是在修改前还复制了一份改之前的保存在局部变量表中,在finally
块执行结束后直接返回拷贝的原变量,finally
块对已经获取返回值的变量的修改不会生效,编译的时候编译器会自己判断return
是否发生在finally
之前,但是不会在字节码指令上表现出来,字节码仍然是先执行finally
然后再执行return
,只是编译器已经处理成返回执行finally
前被复制的返回值
这里总结一下:try-catch-finally 中的return返回值,采取就近原则,如果在执行finally之前遇到了return,那么在finally中修改之前return返回的变量,将不会生效
xxxxxxxxxx
public static String func(){
String str = "hello";
try{
return str;
}
finally{
str="atguigu";
}
}
注意一旦return
在finally
后面执行,那么fianlly
对原变量的修改仍然会生效
这个例子返回的是atguigu
xxxxxxxxxx
public static String func(){
String str = "hello";
try{
int i=1;
}
finally{
str="atguigu";
}
return str;
}
1️⃣athrow
:抛出异常指令,Java
中的throw
语句都是由athrow
指令来实现的,正常创建对象的方式创建异常对象,然后调用athrow
指令抛出;只要执行了athrow
指令当前栈帧会直接被销毁
同步控制指令
同步有同步方法[方法级的同步]和同步代码块[方法内部一段指令序列的同步],两种同步方式都是通过同步监视器[管程]来实现和控制的
方法级的同步是隐式的,不管方法是否加synchronized
关键字,方法编译出来的字节码指令都长一个样,也不会使用monitorenter
和monitorexit
进行同步区控制,方法调用时虚拟机通过方法表结构的ACC_SYNCHRONIZED
访问标志得知一个方法是否被声明为同步方法,如果设置了ACC_SYNCHRONIZED
访问标志,执行线程会先持有同步锁然后菜户执行方法,并在方法正常完成或者非正常完成时[异常抛到同步方法外时]释放同步锁,加锁和释放锁都由JVM
自动进行控制
方法内指定指令序列的同步:synchronized
同步块需要monitorenter
和monitorexit
两条指令支持,当一个线程进入同步代码块时会使用monitorenter
指令请求进入,如果同步对象的监视器计数器为0
就会允许线程进入同步代码块;如果同步对象的监视器计数器为1
会判断持有监视器的线程是否为进入同步代码块的线程,如果是就进入同步代码块,如果不是则进行等待;线程退出同步块的时候使用monitorexit
声明退出,JVM
中任何一个对象都有一个监视器与之关联,用来判断对象是否被锁定,监视器被持有后对象处于锁定状态
monitorenter
和monitorexit
执行时都需要在操作数栈压入同步对象[也称为同步监视器],每个对象的对象头中的运行时元数据中都有一个锁状态标识,最初为0
,当有线程执行monitorenter
占有同步监视器后锁状态标识就会改成1
1️⃣monitorenter
:同步代码块握有同步监视器时会先向局部变量表中保存同步监视器,然后向操作数栈压入同步监视器引用地址,monitorenter
执行时会去检查同步监视器的对象头中的锁状态标识,如果符合条件就将增加锁状态计数并且在同步监视器中的owner
中记录握有锁的线程,不满足条件就进行等待直到符合条件;一旦当前线程握有同步监视器,线程就会进入同步代码块执行同步代码
2️⃣monitorexit
:向操作数栈压入同步监视器的引用地址,monitorexit
执行时会将同步监视器对象头中的锁状态标识减去1
释放锁退出同步代码块
同步代码块会自动在异常表添加对任何类型的异常处理,异常的处理方法是将异常对象保存到局部变量表,向操作数栈压入监视器的引用地址,调用monitorexit
释放锁,向操作数栈压入异常对象,通过athrow
指令手动抛出异常,然后执行return
指令结束方法的执行;此外如果在出现异常释放锁期间又出现异常了,又会通过异常表的方式再次跳转到捕获所有异常释放锁的字节码指令,异常表中的两个异常共用的一块异常捕获处理字节码指令
面试题:i++
和++i
的区别[--
是一样的道理]
int i=10;i++;
的字节码
xxxxxxxxxx
0 bipush 10
2 istore_1
3 iinc 1 by 1
6 return
int i=10;++i
的字节码
从两者的字节码来看,++i
和i++
没有任何区别,在循环中使用++i
或者++i
效果是一样的
注意iinc 1 by 1
是将局部变量表中索引为1
的变量值加1
,不是操作操作数栈中的数据
xxxxxxxxxx
0 bipush 10
2 istore_1
3 iinc 1 by 1
6 return
int i=10;int a=i++;
从字节码上来看是将局部变量表中的索引为1
的变量值压栈操作数栈,然后将局部变量表中索引为1
的变量值加1,然后将操作数栈中的变量值10
存入局部变量表索引为2
的Slot
槽中
宏观上来看就是先将i
的值赋值给a
,然后变量i
再自增1
xxxxxxxxxx
0 bipush 10
2 istore_1
3 iload_1
4 iinc 1 by 1
7 istore_2
int i=10;int a=++i;
从字节码上来看是先将局部变量表中索引为1
的变量值加1,然后将局部变量表中索引为1
的变量值11
压栈操作数栈,然后将操作数栈中的变量值11
存入局部变量表中索引为2
的Slot
槽中
宏观上来看就是先变量i
自增1
,然后在将变量i
赋值给a
记住有赋值操作+
在前面先自增,归根结底是将变量a
的值是在自增前还是自增后压栈操作数栈,而自增操作不需要经过操作数栈,直接更改局部变量表中的值
根据以上规律int i=10;i=i++;
最后i
的值还是等于10
,按直觉看起来好像应该是11
,实际上自增在后,压榨在前
xxxxxxxxxx
0 bipush 10
2 istore_1
3 iinc 1 by 1
4 iload_1
7 istore_2
简述TLAB
[Thread Local Allocation Buffer]
多线程并发访问堆区的共享数据存在线程安全问题,为了避免多个线程同时创建操作同一个资源,需要对线程加锁来保证每个线程操作该资源的原子性从而保证线程并发安全,但是加锁会影响程序执行效率
TLAB
是在伊甸园区给每个线程分配一个私有的缓存区,每个线程优先在缓存区内创建对象,缓存区没空间了才在公共的伊甸园区创建对象,以提升系统的吞吐量并避免线程安全问题,这种内存分配方式被称为快速分配策略,所有由OpenJDK
衍生出来的JVM
都提供了TLAB
的设计
默认情况下TLAB
空间非常小,只占伊甸园区容量的1%
,JVM
只是将TLAB
作为内存分配的首选,但是并不是所有的对象都能在TLAB
中成功分配内存,通过JVM
参数-XX:useTLAB
可以设置开启TLAB
空间[默认情况下是开启的],还可以通过JVM
参数-XX:TLABWasteTargetPercent
设置TLAB
占伊甸园区容量的百分比大小;TLAB
的默认大小为(Eden*2*1%)/线程个数
一旦对象在TLAB
分配内存空间失败,会直接在伊甸园区再分配一个新的TLAB
分配内存,如果伊甸园区内存空间不足无法分配TLAB
会尝试进行YGC
并继续从伊甸园区分配TLAB
给线程分配对象;如果无法申新的TLAB
,此时就会直接在堆内存上分配对象,JVM
尝试采用加锁的方式来保证多线程下的直接在堆内存分配对象的原子性
注意这里老师没讲清楚,弹幕补充:TLAB
是为了解决线程内存分配的问题,两个线程可能争抢一块内存区域。但是TLAB内存的数据还是可以被线程共享的,还是存在线程安全问题,这里只是把TLAB
理解成避免多个线程在为对象分配内存时争抢同一块内存区域将原来的加锁分配内存变成了每个线程在自己的TLAB
中为对象分配内存,分配好的对象仍然可以被多线程共享且存在线程安全问题;所有线程共享堆内存,对内存使用全局的top
分配指针标记下一个可用的内存位置,多个线程尝试分配内存时会更新这个全局指针,为了保证内存分配的线程安全,如果没有TLAB
,JVM
会在更新全局指针时让多个线程竞争锁,降低内存分配的性能;注意分配TLAB
给线程的过程是要加锁的,JVM
使用CAS
锁来为线程分配TLAB
TLAB
存不下新对象重新从伊甸园区为线程分配一块新的TLAB
,旧的TLAB
的剩余空间不会再继续使用,而且单个TLAB
本身空间就比较小只占伊甸园区的1%
还要除以线程总数,很容易就会出现剩余空间无法存放新对象的情况,TLAB
的内存浪费现象比较严重,这会导致JVM
运行过程中始终有一部分内存无法被使用,为此JVM
使用最大浪费空间对TLAB
进行约束,当TLAB
剩余空间存不下新对象且剩余空间小于最大浪费空间,TLAB
所属线程会向JVM
申请一块新的TLAB
区域存储新对象,如果新TLAB
仍然存不下,对象会被直接分配到伊甸园区;如果当前TLAB
的剩余空间大于最大浪费空间,对象会被直接分配到伊甸园区;默认最大浪费空间的JVM
参数TLABRefillWastePraction
为64
,表示值为TLAB
大小的1/64
简述Tomcat
类加载机制
简述堆环境变量Classpath
的理解,如果一个类不在classpath
下为什么会抛出ClassNotFoundException
,不改变该类的类路径前提下如何正确加载这个类
调优概述
生产环境可能发生的问题场景
内存溢出
服务器应该分配多少内存
垃圾回收器如何调优
CPU
飚高如何处理
应该分配多少个线程
没有日志的情况下如何确定请求是否执行了某一行代码
没有日志的情况下如何实时查看某个方法的入参和返回值
调优的目的
避免发生OOM
,发生OOM
以后要能解决OOM
问题,减少Full GC
出现的频率
需要针对应用上线前、项目运营中和线上出现OOM
的几个场景下分别对JVM
进行有不同侧重的调优
上线前测试发现CPU
飚高、请求延迟高、TPS
偏低、内存泄漏和内存溢出等
运行期间需要对生产环境进行监控,比如查看分析GC
日志、运行日志、异常堆栈、线程快照、堆转储快照等
性能调优的一般步骤
其实就是发现问题、排查问题和解决问题,拽名词可以分别说成性能监控、性能分析、性能调优
性能监控主要监控GC
频率、CPU
占用、OOM
、内存泄漏、死锁,程序响应时间较长
通过性能监控工具收集应用运营性能数据来发现系统存在的问题
性能分析指针对某种问题使用各种工具主动排查问题出现的原因,性能分析一般是在开发和测试环节下进行
导出GC
日志,使用GCviewer
或者一些线上的工具比如gceasy.io
来分析日志信息;
使用jdk
提供的命令行工具比如jstack
查看堆栈信息、jmap
、jinfo
等排查分析系统性能;
dump
出堆快照,使用内存分析工具比如Eclipse
的MAT
来分析堆的情况;
使用可视化工具比如阿里的Arthas
、或者jdk
自带的jconsole
、JvisualVM
来实时查看JVM
的状态
性能调优指为了改善系统吞吐量、响应时间针对性地更改配置参数、代码,主要目的是减少GC
频率,Full GC
出现的次数,在保证响应时间和吞吐量的前提下尽可能降低内存的使用量;性能调优方案只能针对具体问题具体分析给出,没有统一的解决办法
根据业务场景选择合适的垃圾收集器、配置合适的堆内存
从业务代码层面控制内存的使用行为
高并发场景下服务器的扩缩容和流控手段
合理设置线程池线程的数量
使用缓存中间件、消息队列等中间件来提高程序运行的效率
系统调优指标
响应时间:用户从提交请求到接收到请求的响应之间的间隔时间,一般关注平均响应时间、多数用户的响应时间,一般请求的响应时间为数据在应用系统中的流转时间与垃圾回收阶段的暂停时间的和
[系统中数据的平均处理时间]
操作 | 响应时间 |
---|---|
打开一个站点 | 几秒 |
查询一条有索引的数据库记录 | 十几毫秒 |
机械磁盘一次寻址 | 4ms |
机械磁盘顺序读取1M 数据 | 2ms |
从SSD 磁盘即固态硬盘读取1M 数据 | 0.3ms |
从远程分布式缓存Redis 读取一个数据 | 0.5ms |
从内存读取1M 数据 | 十几微秒 |
Java 本地方法调用 | 几微秒 |
局域网网络延迟 | 十毫秒 |
在网络中传输2KB 数据 | 1微秒 |
[垃圾收集环节的暂停时间]
像G1
这种比较新的垃圾收集器都能通过JVM
参数-XX:MaxGCPauseMillis
来设置暂停时间的最大值
吞吐量:单位时间内完成响应的请求的数量
垃圾回收中吞吐量指用户线程运行时间占总运行时间的比例,吞吐量可以通过JVM
参数-XX:GCTimeRatio
来设置用户线程运行时间和垃圾收集线程运行时间的比例
从应用的角度上来看吞吐量主要受并发数和响应时间的影响,并发数低响应速度即使很快吞吐量也不会高,随着并发数的增加响应速度变慢了但是吞吐量会增加,并发数太高导致响应速度太慢吞吐量反而会下降当并发数超过系统瓶颈吞吐量变为0
从JVM
调优上主要关注响应时间和吞吐量两个指标
并发数:同一时刻,对服务器产生十几交互的请求数,一般来说并发数为在线人数的5-15%
之间,比如1000
的在线人数并发量在50-150
之间
内存占用:主要关注堆区占用实际内存的大小,一般内存溢出都是堆区发生的,元空间有可能但是机会不大
命令行工具
JDK
除了解释运行Java
程序,还提供了一系列监控JVM
运行情况的工具,这些工具都在JAVA_HOME/bin
目录下,工具源码在JAVA_HOME/lib/tools
的jar
包中,都是打包后的字节码文件;源码可以在https://hg.openjdk.java.net/jdk/jdk11/file/1ddf9a99e4ad/src/jdk.jcmd/share/classes/sun/tools
查看,只有涉及到修改命令行的功能才需要看源码,一般只需要掌握命令行工具的使用;这些命令在windows
和linux
中是一样的用法
命令行工具的局限性
无法获取如方法间的调用关系、方法的调用次数、方法的调用时间等方法级别的分析数据,这些数据对定位系统性能瓶颈至关重要
需要用户登录JVM
进程所在宿主机,数据的结果展示和分析结果通过终端输出,不够直观
JPS
[Java Process Status
]:查看指定操作系统内所有HotSpot
虚拟机进程状态
JPS
:查看当前操作系统上运行的所有JVM
进程和对应进程号,JPS
本身也是一个JVM
进程,IDEA
也是JVM
进程,但是进程名不显示
jps [options] [hostid]
:options
是一系列可选配置参数,可选择的参数有
jps -q
:仅显示JVM
进程号,不显示主类名称;
jps -l
:显示进程号和进程主类名称,而且主类名称会显示为全类名;如果执行的是jar
包则输出jar
包的绝对路径
jps -m
:除了进程号和主类名称,额外展示虚拟机进程启动时用户手动给主方法传递的参数值
jps -v
:列出虚拟机启动时配置的JVM
参数,还会显示用户没有手动指明的部分JVM
参数
jps -l -m
:多个配置参数可以组合使用,显示进程号、主类全类名以及主方法的形参参数值,也可以写成jps -lm
,但是有些参数组合之间是冲突的,比如-q
和-l
,可能会报错非法参数;-lmv
是可以任意组合使用的
hostid
用于查看远程主机上的Java
进程,这种情况下需要安装jstatd
配合使用,这种使用jstatd
远程访问Java
进程的方式很容易受到IP
地址欺诈攻击,建议只在本地使用jstat
和jps
工具
如果Java
进程关闭了默认开启的JVM
参数-XX:-UsePerfData
,此时jps
命令无法探知该Java
进程
jstat
[JVM Statistics Monitoring Tool
]:监控JVM
各种运行状态信息的命令行工具,可以显示本地或者远程虚拟机进程中的类装载、内存、垃圾收集、JIT
编译等运行数据,没有GUI图形界面只有纯文本控制台的服务器环境下运行期定位虚拟机性能的首选工具,一般用于检测垃圾回收问题和内存泄漏问题,官方提供的jstat
文档https://docs.oracle.com/javase/8/docs/technotes/tools/unix/jstat.html
语法格式:jstat -<option> [-t] [-h<lines>] <vmid> [<interval> [<count>]]
option
可选配置
配置项 | 配置含义 |
---|---|
-class | 显示类加载器相关信息 显示类的装载、卸载数量和两者对应字节数 类装载消耗时间等类加载器信息 实例: jstat -class 9000 |
-compiler | 显示JIT 编译器编译过的方法数量、耗时信息实例: jstat -complier 9000 |
-printcompilation | 显示被JIT 编译器编译过的具体方法实例: jstat -printcomplication 9000 |
-gc | 显示与GC 相关的堆信息Eden 、两个Survivor 、老年代、永久代的总容量、已使用空间以及GC 的合计时间SOC :幸存者0区总容量S1C :幸存者1区总容量S0U :幸存者0区已经使用的容量S1U :幸存者1区已经使用的容量EC :伊甸园区总容量EU :伊甸园区已经使用的容量OC :老年代总容量OU :老年代已经使用的容量MC :方法区总容量MU :方法区已经使用的容量OGSC :压缩类的总容量GCSU :压缩类已经使用的容量YGC :YGC 发生的次数YGCT :YGC 耗费的时间FGC :FGC 发生的次数FGCT :FGC 耗费的时间GCT :GC 耗费的总时间,就是YGCT +FGCT |
-gccapacity | 显示内容基本与-gc 相同,主要侧重Java 堆的最大最小空间 |
-gcutil | 显示内容基本与-gc 相同,主要侧重堆各区域已使用空间占最大空间的百分比 |
-gccause | 显示内容与-gcutil 相同,额外输出一个字段表示导致最后一次或者当前正在发生的GC 原因 |
-gcnew | 显示新生代的GC信息 |
-gcnewcapacity | 显示内容与-gcnewcapacity 基本相同,主要侧重区域的最大最小空间 |
-gcold | 显示老年代的GC信息 |
-gcoldcapacity | 显示内容与-gcold 基本相同,主要侧重区域的最大最小空间 |
vmid
:虚拟机的进程id
interval
:指定jstat
指令的查询统计结果打印输出间隔,单位是毫秒,默认情况下只会打印一次
只指定interval
不指定count
的情况下会持续打印统计结果直到被统计的JVM
进程结束,直接Ctrl+C
也能终止掉命令行的执行
示例:jstat -class 9000 1000
count
:指定jstat
指令的查询统计结果的打印输出总次数,该参数一般要和interval
参数搭配才能使用
示例:jstat -class 9000 1000 10
-t
:在统计结果中添加一个字段显示被监控的JVM
进程从启动到当前时刻经历了多长的时间,单位是秒
示例:jstat -class -t 9000 1000 10
-h
:每输出多少次统计结果就额外打印一次表头,因为统计结果以动态表结构的形式给出,防止打印次数太多导致查询每个字段的含义比较麻烦,可以通过该参数配置间隔多少次输出打印一次表头信息
示例:jstat -class -t -h3 9000 1000 10
表示每隔3次统计数据打印就额外打印一次表头
应用举例
生产中,一般可以通过jstat
拉取GC
数据通过计算一段时间段内GC
的时间占总时间的比例来评估系统的性能,如果GC
时间占运行时间的比例超过20%
,说明堆的压力比较大,如果比例超过90%
,说明堆里几乎没有什么可用空间,随时都可能抛出OOM
此外还可以根据统计学来评估系统性能,比如从一段时间内获取统计数据中的OU
即老年代内存大小的最小值,如果发现程序运行过程中,这个内存值一直在增长,说明GC
对老年代的回收不彻底,有些数据一直无法被回收且这样的数据还一直在产生,随着时间推移肯定会出现OOM
jinfo
[Configuration Info for Java
]:查看和修改JVM
的配置参数信息,jps
只会显示全部用户指定的JVM
参数和少部分JVM
自带的参数,想查找任意的JVM
参数使用jinfo
比较方便,去官方文档找比较麻烦;jinfo
修改参数值会立即生效,但是不是所有的参数值都支持动态修改,只有被标记为manageable
的参数值才可以被修改,能被jinfo
修改的参数非常有限,只有16个参数可以通过jinfo
进行修改,但是JVM
参数多达600
多个,注意通过java -XX:+PrintFlagsFinal
打印出来有732
个
jinfo -sysprops PID
:用于查看指定Java
进程的系统属性,这些系统属性可以通过System.getProperties()
获取
jinfo -flags PID
:会显示曾经被覆值过的JVM
参数,会显示非默认参数和命令行参数,命令行参数就是用户指定的JVM
参数,非默认参数包含用户指定的命令行参数和JVM
根据系统环境自己调整过的非默认参数
jinfo -flag 指定参数名 PID
:查看指定参数名的参数值,比如jinfo -flag UseParallelGC 3540
会返回-XX:+UseParallelGC
;jinfo -flag MaxHeapSize 3540
会返回-XX:MaxHeapSize=104857600
java -XX:+PrintFlagsFinal -version | grep manageable
可以查看所有被标记为manageable
的参数,windows
上对应的命令为java -XX:+PrintFlagsFinal -version | findstr manageable
这些可更改的参数分为布尔类型和值类型,分别对应不同的修改指令结构
xxxxxxxxxx
C:\Windows\system32>java -XX:+PrintFlagsFinal -version | findstr manageable
intx CMSAbortablePrecleanWaitMillis = 100 {manageable}
intx CMSTriggerInterval = -1 {manageable}
intx CMSWaitDuration = 2000 {manageable}
bool HeapDumpAfterFullGC = false {manageable}
bool HeapDumpBeforeFullGC = false {manageable}
bool HeapDumpOnOutOfMemoryError = false {manageable}
ccstr HeapDumpPath = {manageable}
uintx MaxHeapFreeRatio = 100 {manageable}
uintx MinHeapFreeRatio = 0 {manageable}
bool PrintClassHistogram = false {manageable}
bool PrintClassHistogramAfterFullGC = false {manageable}
bool PrintClassHistogramBeforeFullGC = false {manageable}
bool PrintConcurrentLocks = false {manageable}
bool PrintGC = false {manageable}
bool PrintGCDateStamps = false {manageable}
bool PrintGCDetails = false {manageable}
bool PrintGCID = false {manageable}
bool PrintGCTimeStamps = false {manageable}
java version "1.8.0_101"
Java(TM) SE Runtime Environment (build 1.8.0_101-b13)
Java HotSpot(TM) 64-Bit Server VM (build 25.101-b13, mixed mode)
jinfo -flag +PrintGCDetails 3540
将布尔类型参数值修改为true
,比如该实例就是将参数PrintGCDetails
从-PrintGCDetails
改成+PrintGCDeatils
,表示启用打印GC
详细信息
jinfo -flag MaxHeapFreeRatio=90 3540
将值类型的参数MaxHeapFreeRatio
的值修改为90
除了jinfo
命令,JDK
还提供了java -XX:+PrintFlagsInitial
命令打印所有JVM
参数的默认值,java -XX:+PrintFlagsFinal
打印所有JVM
参数的实际值,java -XX:+PrintCommandLineFlags
打印被用户设置过或者被JVM
自动设置过的JVM
参数
java -XX:+PrintFlagsFinal
指令获取的结果修改过的值在具体值前面会显示为:=
,没被修改过的值会显示为=
jmap
[JVM Memory Map
]:导出内存映像文件,该命令的作用一方面是获取堆转储快照dump
文件,获取离当前最近一个安全点时刻堆中对象占用内存大小的记录,还可以指定不同的参数获取目标Java
进程的内存信息包括Java
堆中各个区域的使用情况,堆中对象的统计信息和类加载信息等,甚至还可以按照对象的大小进行排序,是一个二进制文件因此不能直接通过文本软件打开,称为dump
文件的原因是命令参数中还有dump
,dump
文件一般发生OOM
或者内存泄漏时用来分析问题的源头;堆转储文件会保存所有的对象、类、GC Roots
、调用栈即Java
中的虚拟机栈信息
基本语法:
jmap [option] <pid>
jmap [option] <executable <core>>
jmap [option] [server_id@] <remote server IP or hostname>
远程访问Java
进程
常见option
参数值
配置项 | 配置含义 |
---|---|
-dump | 生成堆转储快照文件-dump:live 只保存堆中的存活对象 |
-heap | 输出当前命令执行时刻的堆空间详细信息比如GC 的使用信息、堆JVM 配置参数信息以及内存的实际使用情况等jmap 只能展示某个时间点的堆情况,jstat 能展示一定时间间隔上的堆情况,GUI 则更高级一些以图形化界面的形式展现堆情况 |
-histo | 输出堆中对象的统计信息、包括类、类实例数量和当前类实例占用的合计容量-dump:live 只保存堆中的存活对象 |
-permstat | 输出永久代的内存信息也就是加载类的信息 仅 linux /solaris 平台有效 |
-finalizerinfo | 输出在F-Queue 队列中等待被Finalizer 线程执行finalize 方法的对象仅 linux /solaris 平台有效 |
-F | 如果使用-dump 参数生成dump 文件时没有任何响应,添加了该参数会强制执行生成dump 文件仅 linux /solaris 平台有效 |
导出内存映像文件
生成Dump
文件前会触发一次Full GC
,dump
文件中保存的都是Full GC
后留下的对象信息
dump
文件生成比较耗时,尤其是大内存镜像生成dump
文件时会更耗时
手动导出dump
文件:导出堆中全部对象的语法为jmap -dump:format=b.file=<filename.hprof> <pid>
[示例:jmap -dump:format-b,file=d:\1.hprof 3450
],导出堆中存活对象的语法为jmap -dump:live,format=b,file=d:\1.hrefo <pid>
[示例:jmap -dump:live,format-b,file=d:\1.hprof 3450
],dump
文件是一个二进制流文件,需要使用专门的像Profile
、jconsole
、jvisualvm
、MAT
这种软件打开,普通的文本软件是无法识别的,也可以使用命令行工具
命令中format-b
的作用是让生成的dump
文件的格式要和hprof
保持一致
堆中的对象越多dump
文件就越大,生产环境中一般dump
文件都会多达几百兆,从机器上下弄下来再一通分析完可能距离出现OOM
大半天就过去了,此时可以选择只下载保存存活对象的dump
文件,这样速度更快,因为OOM
一般发生也是由于GC
回收不走的对象导致
出现OOM
时自动导出dump
文件:因此系统出现OOM
时会自动退出系统,此时瞬时信息都随着程序的终止而消失,因此需要在发生OOM
的时候自动导出一份dump
文件方便故障排查
通过配置JVM
参数-XX:+HeapDumpOnOutOfMemoryError
在程序发生OOM
时导出应用程序的当前堆快照,通过配置JVM
参数-XX:HeapDumpPath
可以指定堆快照的保存位置[配置示例:-Xmx100m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=D:\m.hprof
]
通过配置JVM
参数-XX:+HeapDumpBeforeFullGC
可以配置当JVM
发生Full GC
前自动导出一份dump
文件
因为JVM
总是到安全点才会停下来导出dump
文件以保证导出dump
文件的过程不会被应用线程干扰,这会导致执行指令时刻到安全点之间的对象如果在这期间被销毁了,会导致配置了:live
指令的快照结果和执行指令时刻的实际情况出现偏差
此外如果某个线程长时间无法跑到安全点,jmap
只能一直等待下去;但是像jstat
这样的指令不要求安全点
jhat
[JVM Heap Analysis Tool
]:JDK
提供的堆分析工具,与jmap
命令搭配使用用于分析jmap
生成的dump
文件,jhat
内置了一个微型的HTTP/HTML
服务器可以查看jhat
对dump
文件的分析结果
jhat
在JDK9
、JDK10
中已经被移除,官方建议使用JVisualVM
来代替
使用命令jhat dump文件带后缀名地址
分析dump
文件,通过结果给出的端口号通过浏览器可以访问到分析结果,一次只支持分析一个dump
文件,还可以通过OQL
语句查询符合条件的对象;不会直接在生产环境使用该指令分析dump
文件
jhat [option] [dumpfile]
,option
可选择的配置参数包括
-stack false|true
:关闭或者打开对象分配调用栈的追踪
-ref false|true
:关闭或者打开对象引用的追踪
-port port-number
:设置jhat
的服务器的访问端口号,默认端口号是7000
-version
:启动后仅打印jhat
的版本就退出
jstack
:打印JVM
中所有线程的虚拟机栈快照[信息很少,并不是虚拟机栈的所有数据都打印,主要是线程和线程状态和一些简单的分析],开发者分析线程的执行状态,线程在运行期间可能由于线程死锁、死循环、调用sleep
、wait
方法、请求外部资源导致长时间等待导致线程出现不正常长时间停顿的问题,我们想整体上把握哪些线程出现了长时间等待的情况可以使用jstack
指令来获取各个线程的运行快照数据来分析得出
官方文档:https://docs.oracle.com/en/java/javase/11/tools/jstack.html
线程快照中会显示当前线程的状态,分别为Deadlock
[死锁]、Waiting on condition
[等待资源]、Waiting on monitor entry
[等待获取监视器]、Blocked
[阻塞]、Runnable
[执行中]、Suspended
[暂停]、Object.wait()
或TIMED_WAITING
[线程等待中]、Parked
[停止]
基本语法:
jstack [option] <pid>
,option
可选配置
-F
:指令不能被正常响应时强制输出线程堆栈信息
-l
:除了堆栈信息额外显示锁的附加信息
-m
:如果调用了本地方法可以显示本地方法栈信息
-h
:jstack
的使用指南
jstack <pid>
:打印当前进程对应的所有线程和相应线程状态,最后还会列举进程中线程存在的问题
Java
中Thread.getAllStackTraces()
获取Map<Thread,StackTraceElement[]>
也能通过Java
程序获取进程中每个线程的状态,信息没有jstack
归纳的那么智能
jcmd
:多功能工具,可以实现除了jstat
外的所有命令行工具的功能,比如导出堆栈、查看内存使用、查看Java
进程、导出线程信息、GC
的执行时间、JVM
的运行时间等;jcmd
拥有jmap
的大部分功能,官方推荐使用jcmd
替代jmap
官方文档:https://docs.oracle.com/en/java/javase/11/tools/jcmd.html
jcmd
:效果和jps -l
的效果相同,额外显示主方法的形参值
jcmd -l
:效果和jps -m
的效果相同
jcmd <pid> help
:查看指定进程的jcmd <pid> 具体命令
支持的所有命令,这些jcmd <pid> 具体命令
就能替换掉此前大部分命令的功能
jcmd <pid> 具体命令
:比如jcmd 8320 Thread.print
,和jstack
命令的效果相同;jcmd 8320 GC.class_histogram
,和jmap -histo
命令的效果相同打印堆中类和类实例数量,内存占用信息;jcmd 8320 GC.heap_dump d:\1.hprof
效果和jmap -dump:format-b,file=d:\1.hprof 8320
效果是一样的;jcmd 8320 VM.iptime
展示指定进程启动到当前时刻的持续时间;jcmd 8320 VM.system_properties
打印可以通过System.getProperties()
获取的系统属性信息;jcmd 8320 VM.flags
:打印非默认值的JVM
参数
jstatd
:一些命令行监控工具比如jps
、jstat
支持对远程计算机的监控,启用远程监控需要配合使用jstatd
工具,jstatd
是一个RMI
服务端程序,作为代理服务器建立本地计算机与远程监控工具之间的通信,jstatd
将本机的Java
应用程序信息传递到远程计算机上供监控工具分析
GUI
工具
JDK
的bin
目录下自带了jconsole
、jvisualvm
、jmc
三个图形化综合诊断工具
jconsole
:用于查看JVM
应用的运行情况、监控堆区、方法区的使用情况和类加载器情况,功能简陋,入门级别的GUI
系统监控工具
visualVM
:jdk
内置了jvisualVM
,用户也可以自己下载visualVM
,相较于jconsole
功能更强大
JMC
[Java Mission Control
]:JMC
是JRockit VM
提供的工具,oracle
收购BEA
后将JMC
整合到HotSpot
中。JMC
中内置了Java Flight Recorder
,能够以极低的性能开销搜集JVM
的性能数据
第三方的GUI
工具
MAT
[Memory Analyzer Tool
]:由Eclipse
开发的堆内存分析工具,可以作为一个插件在Eclipse
上使用,也可以独立安装使用。一般用于帮助开发者查找内存泄漏和减少内存开销
JProfiler
:相较于visualVM
功能强大的商业软件
Arthas
:国内比较流行的阿里开源的Java
诊断工具
Btrace
:Java
运行时追踪工具,可以在不停机的情况下追踪指定方法、构造方法的调用和系统内存信息
康师傅推荐必须掌握visualVM
,在此基础上掌握Arthas
、然后是JProfiler
,在dump
文件分析方面掌握MAT
JConsole
:
介绍:从JDK5
开始自带的java
监控和管理控制台,用于对JVM
中内存、线程和类的监控,是一款基于JMX
[java management extensions
]的GUI
性能监控工具
官方文档:https://docs.oracle.com/javase/7/docs/technotes/guides/management/jconsole.html
JVM
进程连接方式
本地:Jconsole
使用文件系统的授权通过RMI
连接器连接到正在本地系统运行的JVM
此外还要求操作系统运行JVM
进程的用户和运行Jconsole
的用户是同一个
远程:使用service:jmx:rmi:///jndi/rmi://hostName:portNum/jmxrmi
通过RMI
连接器连接到一个JMX
代理,还需要在环境变量中设置mx.remote.credentials
指定用户名和密码为远程JVM
进程授权来实现JConsole
和远程JVM
进程之间建立连接
jconsole
的监控面板
概述:
以折线图的方式展示堆内存使用量、加载线程数、加载类的个数、CPU占用率随时间的变化
内存:
展示堆[可选老年代、伊甸园区、幸存者区]和非堆内存[可选元空间、代码缓存、压缩类]占用量随时间的变化折线表
可以点击执行GC
按钮让JVM
强制执行一次GC
点击堆dump
可以生成dump
文件
线程:
显示加载的总线程数,每个线程的详细信息,还可以通过检测死锁按钮来判断系统是否发生了死锁
类:
显示加载类的总数量
VM
概要:
JVM
进程运行、配置情况概述
Visual VM
介绍:Visual VM
是一款功能强大的故障诊断和性能监控的可视化工具,几乎将所有的命令行工具都整合到Visual VM
中,可以显示指定操作系统中的全部虚拟机进程,进程的系统参数配置、环境信息,监视CPU、GC、堆、方法区、线程的信息,Visual VM
可以完美地取代JConsole
。是JDK1.6u7
开始引入的,JDK8
后期版本及更高的版本需要从官网下载独立版本,官网:https://visualvm.github.io/index.html
,visualVM
是独立的软件,被JDK
整合到bin
目录下作为标准工具组件,因为这些组件都以j
开头,因此JDK
整合visual VM
就被起名为jvisualVM
体现为JDK
的一部分,一般独立安装的visual VM
就叫visual VM
Visual VM
支持插件扩展,插件安装可以通过以.nbm
作为后缀的离线插件文件,插件对话框中已下载页面添加已下载的插件或者在线安装插件,一般建议安装内存空间可视化柱状图插件VisualGC
;
github
上visualvm
的插件中心:https://visualvm.github.io/pluginscenters.html
,点进不同的visualVM
版本后可以查看当前版本可以安装的插件列表,开发者可以在这里下载插件文件到本地
也可以在visual VM
的插件面板搜素具体插件直接安装,但是需要提前对插件中心进行配置,否则安装插件可能会失败,配置过程参考谷粒商城中对jvisualVM
的配置文档
IDEA中也可以安装VisualVM Launcher
插件,这个插件的作用只是启动程序的同时也自动启动jvisualVM
一个Visual VM
可以同时监控多个Java
进程
Visual VM
的功能比较强大,最起码需要掌握的可视化工具就是viusalVM
连接方式:
本地连接:visualVM
界面本地可连接的JVM
进程,点击就能自动进行连接
远程连接步骤:
在远程服务器上启用目标Java应用程序时添加JMX
远程监控支持
xxxxxxxxxx
java -Dcom.sun.management.jmxremote \
-Dcom.sun.management.jmxremote.port=12345 \
-Dcom.sun.management.jmxremote.authenticate=false \
-Dcom.sun.management.jmxremote.ssl=false \
-Djava.rmi.server.hostname=远程服务器IP地址 \
-jar your-application.jar
-Dcom.sun.management.jmxremote.port=12345
:指定JMX
服务监听的端口
-Dcom.sun.management.jmxremote.authenticate=false
:禁用身份验证[生产环境中建议启用]
-Dcom.sun.management.jmxremote.ssl=false
:禁用SSL
[生产环境中建议启用]
-Djava.rmi.server.hostname
:指定远程服务器的 IP 地址或主机名
配置远程服务器的防火墙策略允许VisualVM
所在机器访问远程服务器的JMX
端口
在 Linux 上可以使用以下命令:
xxxxxxxxxx
firewall-cmd --zone=public --add-port=12345/tcp --permanent
firewall-cmd --reload
本地启动VisualVM
[独立的VisualVM
需要保证和JDK
版本的兼容性]
在VisualVM
主界面点击文件--添加远程主机,输入远程服务器的IP
地址添加远程主机
右键点击刚刚添加的远程主机,选择添加JMX
连接,输入远程主机的JMX
端口添加JMX
连接
连接成功并监控Java
进程的CPU
使用率、内存使用情况、线程状态等运行数据
生产环境建议启用身份验证和SSL
增强安全性[可选],可以通过以下参数启用:
jmxremote.password.file
:包含用户名和密码的文件。
jmxremote.access.file
:定义用户权限的文件。
xxxxxxxxxx
-Dcom.sun.management.jmxremote.authenticate=true \
-Dcom.sun.management.jmxremote.password.file=/path/to/jmxremote.password \
-Dcom.sun.management.jmxremote.access.file=/path/to/jmxremote.access \
-Dcom.sun.management.jmxremote.ssl=true
基础功能
生成读取堆转储内存快照文件
进程列表中指定进程鼠标右键--堆Dump
生成dump
文件
监视/内存面板点击堆dump
生成dump
文件
该dump
文件需要另存为才能保存下来,否则Visual VM
一关闭就会丢失
文件--装入,选择堆Dump
,选择目标dump
文件就能展示dump
文件中的信息
dump
文件分析面板可以点击与另一个dump
文件进行比较,统计两个指定dump
文件之间的差异信息
查看JVM
参数和系统属性
查看指定主机中的所有Java
进程
生成读取线程快照
线程dump
文件和堆dump
文件一样有两种生成方式,保存需要另存为,内容和jstack
命令生成的内容是相同的
也可以读取线程dump
文件到visualVM
中
实时监控CPU、GC、堆、方法区、线程的信息
JMX
代理连接、远程Java
进程监控
CPU
、内存抽样
在抽样器面板可以对CPU或者内存进行抽样,对CPU抽样中的CPU样例面板会将占用CPU时间比较长的方法以列表的形式展示出来,线程CPU时间会将每个线程占用CPU时间的多少展示出来并根据占用时间对线程进行排序
内存抽样的堆柱状图会实时展示类的实例个数和占用内存的大小,点击对应的类会显示所有的类实例,与另一个dump
文件比较会显示两个dump
文件类实例的统计数据差异
Eclipse MAT
介绍:Java
堆内存分析工具,查找分析内存泄漏和内存消耗情况。MAT
的主要功能就是分析dump
文件,不像visualVM
一样除了分析dump
文件还有比较强的实时监控功能,但是MAT
对dump
文件的分析功能做的更好一些;MAT
是Eclipse
的一个插件,只是该插件可以单独下载使用,可以在官网https://www.eclipse.org/mat/downloads.php
下载,解压点击可执行文件就能运行,使用MAT
打开堆转储内存映像hprof
文件就可以看到以下内存信息
所有对象实例、成员变量、存储在虚拟机栈中的基本数据类型值和堆中对象的引用值即对象信息
所有类加载器、类名称、父类、静态变量等类信息
GCRoot
到所有可达对象的引用路径
线程的调用栈和线程的局部变量等线程信息
MAT
认为的内存泄露只要生命周期太长,比如一个局部变量出了作用范围但仍然被长生命周期对象引用,MAT
也会认为这是一个内存泄漏点;像静态变量强引用一个对象,因为静态变量的生命周期和类的生命周期一致,静态变量强引用的对象就可能因为一直无法被回收导致内存泄漏
MAT
只能处理主流厂家如SUN
、HP
、SAP
的HPROF
二进制堆转储文件,同时也能解析IBM
的PHD
堆存储文件
MAT
最实用的功能是生成内存泄漏报表,像jhat
或者其他的工具都只能展示原始的信息,分析结构展示的不够直观,MAT
提供的内存泄漏报表能帮助开发者方便地定位问题和分析问题;MAT
无法做到一键展示系统所有内存问题的程度,很多内存问题还是需要开发者根据MAT
展示的信息通过经验和直觉具体问题具体分析来进行判断
实际生产中一般都是使用MAT
对dump
文件进行分析,dump
文件可以通过jmap
命令生成、可以通过JVM
参数配置在FullGC
或者发生OOM
以前自动生成、还可以在visualVM
中导出、MAT
本身点击File--Acquire Heap Dump也能从弹窗的活跃Java
进程中导出堆快照
MAT
通过点击File--Open Heap Dump可以打开一个dump
文件,使用MAT
打开一个dump
文件会生成很多不同格式的文件,弹窗选择Leak Suspects Report
点击finish
还能生成一个zip
格式的压缩包
导入dump
文件会弹窗并提供三个选项
Leak Suspects Report
是生成泄漏疑点报告,会自动检查堆快照检查哪些是内存泄漏疑点,报告哪些对象仍然存活且没有被垃圾收集的原因,这也是默认选项
Component Report
是组件报告,自动分析像重复字符串、空集合、终结器、弱引用等一系列可能发生内存问题的被怀疑对象
Re-open previously run reports
是打开之前已经存在的泄漏疑点报告或者组件报告,这个报告保存在hprof
相同目录的压缩文件中
使用MAT
分析dump
文件
Overview
面板:内存情况概述
Details
显示堆空间大小、加载类的个数、对象实例个数、类加载器个数,不可达对象直方图
Biggest Object by Retained Size
显示可达对象中最大对象的内存占用情况,将光标悬停会在左侧边栏展示对象的详细信息
Actions
显示类的直方图、最大对象的支配树[显示当前对象引用哪些对象以及被哪些对象引用]、Top Consumers
根据类和包显示内存占用最大的类和实例数据、Duplicate Classes
检测被不同类加载器加载同一个字节码文件生成的不同类
Reports
显示各种报告:包含内存泄漏疑点报告[会列举内存泄漏的怀疑点]、
histogram
[直方图]:
对应jmap
的histo
命令参数、visualVM
中也有,列举每种类的实例数、浅堆占用大小、深堆占用大小
选中一个类会显示类的包、class
实例位置、父类、加载使用的类加载器、有没有GC Root
工具栏有一个下拉列表可以选择将直方图中的类数据按照类、父类、类加载器、包进行分组,默认是按照类进行分组,还可以在列表顶部写正则表达式来检索目标类
选中一个类右键选择Merge Shortest Paths to GC Roots
并选择exclude all phantom/weak/soft etc.references
排除所有的弱引用、软引用、虚引用,展示当前类中有哪些实例在哪些根节点中被强引用
直方图工具栏最后有一个Compare
按钮,可以选择另一个dump
文件比较两个dump
文件类统计数据直方图之间的差异,差异数据可以选择根据类实例数量等维度进行排序,观察一段时间内导致内存增长最快的类和类实例
支配树[Dominator Tree
]:
概念:在对象引用图中,所有指向对象B
的完整路径都经过对象A
,则认为对象A
支配对象B
,如果对象A
是距离对象B
最近的一个支配对象,我们就认为对象A
是对象B
的直接支配者
判断一个对象B
的直接支配者只需要判断要想访问对象B
最近只能通过哪个对象
支配树里面的是对象的支配树关系
支配树的性质:
对象A
的子树,即所有被对象A
支配的对象集合加上对象A
本身,就是对象A
的保留集
如果对象A
支配对象B
,那么对象A
的直接支配者也支配对象B
支配树的边和对象引用图的边不直接对应
支配树的意义
回收一个对象可以将其支配树下的子树全部回收掉,因为这些子树上的对象只能通过被回收的对象访问
浅堆和深堆:
浅堆[Shallow Heap
]:指单个对象实例本身占用的内存大小,属性中基本数据类型计算属性值本身占用大小、引用数据类型计算引用地址的大小而非引用地址指向的实际对象实例的大小;一个类的所有实例的浅堆大小占用只可能小于等于深堆大小占用
32位操作系统中一个对象引用占4个字节,一个int
类型占4个字节,一个long
类型占8个字节,每个对象头占用8
个字节,最后如果对象内存不够8字节的整数倍还会向8字节对齐
比如JDK
中的String
,有两个int
类型的属性hash32
和hash
,一起占8个字节;一个引用数据类型的属性value
占4个字节;对象头占8个字节;一个String
对象占20
个字节,但是要补齐8字节的倍数,因此一个String
对象占用24
个字节,也就是String
对象的浅堆大小就是24
个字节
数组对象的浅堆大小为对象头8
个字节加数组长度四个字节加数组长度个元素长度字节,比如Object[22]
的长度为8+4+22*4=100
个字节。而且是数组长度不是元素个数,ArrayList
的初始长度为10
,扩容为原来的1.5
倍,因此扩容后的数组长度为15
,但是元素个数必然少于15
,但是数组对象长度还是按照15
个元素来计算
深堆[Retained Heap
]:
保留集[Retained Set
]:对象A
的保留集指对象A
被垃圾收集后,可以被连带释放的所有对象的集合再加上对象A
本身,即对象A
的保留集指仅被对象A
直接或者间接引用的对象以及对象A
本身
深堆:对象的保留集中所有对象的浅堆大小之和,一个对象的深堆大小即一个对象被回收后可以真正被释放的内存空间
对象的大小:对象的实际大小定义为一个对象能触及的所有对象的浅堆大小之和,因为包含了不仅仅只被当前对象引用的对象,因此对象的大小大于等于深堆大小,但是这个概念和垃圾回收无关
OQL
查询语句:
在jhat
、visualVM
、MAT
中通过OQL
语句过滤掉大部分无用信息检索出目标数据,MAT
可以通过首页第四个按钮进入OQL
面板,OQL
面板点击F1
可以展示帮助侧边栏;OQL
语句不能加分号,通过快捷键F5
执行,如果有多条OQL
语句需要选中语句后再通过快捷键F5
执行
Select
子句
SELECT * FROM java.util.Vector v
:*
表示以地址引用的形式显示当前JVM
进程中指定类java.util.Vector
的所有实例
SELECT objects s.value FROM java.lang.String s
:onjects
表示以对象的形式显示当前JVM
进程中指定类java.lang.String
的所有实例
SELECT AS RETAINED SET * FROM java.lang.String
:AS RETAINED SET
表示显示当前JVM
进程中指定类java.lang.String
的所有实例的保留集
SELECT DISTINCT OBJECTS classof(s) FROM java.lang.String s
:DISTINCT
用于在查询结果集中去除重复的对象
SELECT v.elementData FROM java.util.ArrayList v
:v.elementData
只会展示每个对象最基本的属性,包括对象所属类型、对象的内存引用地址、对象如果是数组会显示数组长度,但是不会有下拉列表显示对象的详细信息;可以使用SELECT objects v.elementData FROM java.util.ArrayList v
以对象的形式展示每条记录并且显示对象的浅堆深堆大小
FROM
子句:用于指定查询范围,可以指定范围为指令类、正则表达式或者对象地址
SELECT * FROM java.lang.String s
:结果集中显示的对象地址引用的类型必须为java.lang.String
SELECT * FROM "com\.earl\..*"
:结果集中显示的对象地址引用的类型必须在包com.earl
下
SELECT * FROM 0x37a0b4d
:结果集中显示的对象地址引用的类型必须是内存地址0x37a0b4d
对应的class
对象对应的类,这个地址可以通过在MAT
中选中class
对象对应类名在侧边栏显示的额外信息中获取
因为一个类比如Student
可能被多个类加载器加载形成多个class
对象,但是我们使用一个内存地址只可能限定一个class
对象,这样能限制结果所处范围只对应一个class
对象
WHERE
子句:指定OQL
语句的查询条件,查询结果只包含满足WHERE
子句指定条件的对象
SELECT * FROM char[] s WHERE s.@length>10
:查询返回长度大于10
的char
数组
SELECT * FROM java.lang.String s WHERE toString(s) Like ".*java.*"
:查询字符串的toString
方法返回值中含有子串java
的所有字符串,Like
操作符的操作数为正则表达式,匹配满足正则表达式的字符串
SELECT * FROM java.lang.String s where s.value != null
:查询字符串的value
属性不为null
的字符串对象
SELECT * FROM java.util.Vector v WHERE v.elementData.@length > 15 AND v.@retainedHeapSize>1000
:返回数组长度大于15
且深堆大小大于1000
字节的所有Vector
类型的对象,WHERE
字节支持多个条件的AND
且、OR
或逻辑组合运算
内置对象与方法:OQL
语句可以访问堆内对象或者堆内代理对象的属性、格式为[<alias>.]<field>.<field>.<field>
,其中alias
为对象名称
SELECT toString(f.path.value) FROM java.io.File f
:查询所有File
实例的path
属性的value
属性
SELECT s.toString(),s.@objectId,s.@objectAddress FROM java.lang.String s
:查询所有String
实例的字符串内容、objectId
和objectAddress
SELECT v.elementData.@length FROM java.util.Vector v
:查询所有Vector
实例的内部数组长度
SELECT * FROM INSTANCEOF java.util.Vector
:查询所有Vector
和其子类实例对象
Thread OverView
[线程概述]:查看当前Java
进程中所有的线程情况,每个虚拟机栈中栈帧的局部变量情况
从工具栏顶部第五个小齿轮按钮可以直接点进线程概述面板
点开每个线程会显示线程调用的方法和每个方法中的局部变量包括局部变量中引用的变量,局部变量有前缀<local>
标识,内存泄露分析报告中可能指出指定线程中的局部变量被怀疑是泄露点并指出对象占用内存在现有对象中占用内存的比例,并指出对象内存占用的具体原因比如哪个属性内存占用太大
每个局部变量右键list objects
--with outgoing references
会显示当前局部变量引用了哪些对象即出引用,右键list objects
--with incoming references
会显示当前对象被哪些对象引用即入引用,这些对象还可以按照上述流程查看哪些对象被当前对象引用或者哪些对象引用了当前对象
线程概述里面的局部变量的关系是对象引用图
报告:
堆dump
报告:堆空间大小、加载类的个数、对象实例个数、类加载器个数,GCRoots
根节点个数、dump
文件格式、dump
文件生成时间、系统属性参数列表、线程概述、类的直方图
JProfile
:
介绍:JProfiler
由ej-technologies
公司开发的Java
性能诊断工具,可以单独使用,也可以作为IDEA
中的一款插件使用,官方下载地址https://www.ej-technologies.com/products/jprofiler/overview.html
;Jprofiler
的功能要比MAT
的功能强大的多,MAT
主要用于分析堆dump
文件,而且比visualVM
还强大,但是收费
特点:
提供了常见的监控配置模版,使用简单、功能强大,支持对在线JVM
进程的分析也支持对离线dump
文件的分析,支持本地JVM
进程也支持远程JVM
进程
对被分析应用的影响相较于其他软件更小,因为性能监控工具需要获取应用的数据肯定会对被监控系统性能造成影响
对CPU
、Thread
、Memory
的分析功能尤其强大
支持对jdbc
、noSql
、jsp
、servlet
、socket
的性能监控分析
跨平台,支持多种操作系统的安装版本[windows
、Mac
、Linux
、FreeBSD
、Solaris
、AIX
、HP-UX
],可以在https://www.ej-technologies.com/download/jprofiler/version_100
选择不同主楼操作系统安装版本进行下载,而且在主流的IDE
中能下载相应的插件
功能:
分析提高被调用方法的性能
分析堆中的对象、引用链以及与GC Roots
的关系来排查内存泄漏问题,优化内存使用
提供多种针对线程和锁的分析视图帮助发现多线程问题
提供高级子系统对JDBC
调用、执行比较慢的SQL
语句等环节进行集成分析
数据采集方式
instrumentation
重构模式:
JProfiler
的全功能模式,在字节码加载前,JProfiler
就会将相关功能代码写入到需要分析的字节码中,会对JVM
进程造成一定影响
优点是功能强大、通过这种方式采集的调用堆栈信息非常准确
缺点是如果要分析的系统的字节码很多,对系统性能的影响比较大,CPU
的开销也比较大,为了节省性能开销,一般都要配合过滤器过滤掉JRE
中现成的class
以及框架中的class
,配置JProfiler
不对这些类进行分析
Sampling
抽样模式
每隔一定时间比如5ms
将每个线程对应虚拟机栈中的方法栈帧中的信息统计出来
只对内存泄漏、内存溢出进行分析使用Sampling
抽象模式进行数据采集即可,对正在运行的JVM
进程也推荐使用抽样模式,JProfiler
本身也推荐使用抽样模式进行数据采集
重点关注线程、CPU、内存的情况
优点:这种数据采集模式即使不配置任何过滤器,对JVM
进程的影响都非常小,对CPU
的开销也非常低
缺点:无法提供JProfiler
的全部功能,比如使用JProfiler
查看某个方法的调用次数和执行时间等监控数据
Telemerties
遥感监控:JProfiler
在demo
目录下提供了一些演示案例方便用户熟悉JProfiler
的功能
Memory
:监测内存随时间的变化,可以选择伊甸园区、老年代、幸存者区、代码缓存、压缩类、元空间以上不同的内存区域进行实时监测,也可以点击工具栏的Run GC
强制执行垃圾回收
GC Activity
:垃圾回收器随着时间变化是否活跃
Classes
:当前JVM
进程加载类的个数随时间变化情况,蓝色为CPU相关的类、绿色为非CPU相关的类
Threads
:显示线程情况
CPU Load
:程序运行期间CPU
使用率随时间的变化,绿色是进程的CPU使用率,蓝色的是整个系统的CPU使用率
Live memory
实时内存:
All Objects
所有对象:展示每个类的类实例直方图以及当前类所有类实例占用的内存大小,这个内存大小是浅堆大小
点击工具栏的Mark Current
就能统计点击以后每个当前类实例数和内存占用大小的变化,也可能因为垃圾回收行为导致实例数减少,而且能通过颜色区分新创建的对象和点击前的旧对象的实时变化
可以选择以类或者包作为统计的最小单元
通过直方图
关注频繁创建的对象,频繁创建的对象要关注是不是创建对象的线程死循环或者循环次数过多;
size
和实例数变化都比较大说明创建对象过于频繁
关注大对象,比如读写文件时我们应该采取小缓冲区边读边写的方式,避免长时间只读不写导致缓冲区byte[]
过大导致内存的利用率过低;
关注是否存在内存泄漏问题
一般每次垃圾回收后最低点的内存大小一直在稳步提升就说明大概率存在内存泄漏问题
Recorded Objects
:
默认情况下没有开启该功能,一般只有怀疑可能存在内存泄漏的时候才会使用该功能做一些相关的分析,开启该功能对对象进行记录会严重降低系统性能
通过Recorded allocations
设置每创建多少个对象记录一次被创建的对象,通过Aggregation level
选择被记录的对象是按照类classes
还是包package
进行分组
选中一条记录右键选择change Liveness Mode
可以选择展示存活对象、已经被垃圾回收对象,存活以及已经被垃圾回收的对象可以切换显示处于不同状态的被记录对象
我们可以从某个时间点开始抽样记录创建的对象,然后通过手动或者自动GC
来判断是否存在一直在创建但是无法被回收的对象以及某些内存占用一直在增长却没有被回收的大对象[一般这种大对象也是因为大量小对象被创建且无法回收导致的]
选中一条记录右键选择Show Selection in Heap Walker
对某个类的所有实例进行堆监测
Allocation Call Tree
:
Allocation Hot Spots
:
Class Tracker
:
Heap Walker
堆监测:
可以点击咖啡按钮对当前监控的JVM
进程生成堆转储HPROF
文件并且会在新窗口展示分析该堆转储文件,也可以只点击相机按钮弹窗新窗口对当前堆快照进行分析而不会生成HPROF
文件
Classes
:显示指定类记录,包含类实例个数和实例占用内存,
右键记录,选择Use Selected Objects
在弹窗中的Reference
选项可以选择Outgoing references
或者Incoming references
即出引用和入引用,可以查看每个实例被哪个对象引用,在哪个虚拟机栈的具体哪个方法被引用
右键每个实例记录,选择Show In Graph
,会显示当前对象引用了那种类型的对象,当前对象又被哪种类型对象引用,点击其中某个对象右键选择Show Paths To GC Root
还可以显示当前对象到GC Root
的引用路径
CPU Viewer
CPU监测:对CPU的追踪监控会对进程性能产生影响,默认是没有开启对应功能的
Call Tree
[调用树]:可以只对指定线程中的方法进行性能监控,也可以对全部线程的方法进行性能监控,也可以筛选过滤指定状态的线程,可以对线程中的方法按照方法、类、包进行分组统计,会显示线程的方法调用树,显示每个方法被调用的时间以及占整个方法调用时间的百分比;其中inv
[invoke
]刻画方法被调用的次数,显示方法所在的类和方法名
Hot Spots
:
Call Graph
:
Method Statistics
:
展示方法多次调用的总时间,调用次数、单次调用平均时间、单次调用中位数时间、单次调用最小时间和最大时间
Complexity Analysis
:
Call Tracer
:
Threads
线程面板:
Thread History
:展示进程中的线程和线程状态,能动态展示一段时间内线程状态的交替变化
线程分析一般关注三个方面
Web
容器的线程最大数比如Tomcat
的最大线程容量
线程是否长期处于阻塞状态
线程是否发生死锁,两个线程一直处于阻塞状态就是发生了死锁
Thread Dumps
:点击可以生成当前时刻的线程dump
文件
Monitors & locks
同步监视器和锁面板:
Arthas
介绍:visualVM
和JProfiler
的优势是通过图形化界面可以看到各个维度的性能数据,缺点是都必须在被监控进程中配置监控参数,通过工具远程连接到项目进程获取数据;但是线上环境的网络一般是隔离的,本地的监控工具要连上线上环境很不方便,而且像JProfiler
这种商业工具需要付费;这两款工具一般用于上线前的性能压力测试、代码调优;上线以后一般通过Arthas
在服务端通过命令行进行调优和性能监控
Arthas
是Alibaba
开源的Java
性能诊断工具,无需重启项目进程就能动态跟踪Java
代码、实时监控JVM
状态在线排查问题;Arthas
支持JDK6+
,支持Linux/Mac/Windows
,采用命令行交互,支持Tab
自动补全;Arthas
是基于Java
程序诊断工具Greys
的二次开发,命令行实现基于termd
开发,文本渲染功能基于crash
的文本渲染功能开发,命令行界面基于vert.x
提供的cli
库开发,使用JavaAgent
在JVM
启动时加载Arthas
的代理代码用于对JVM
进程的动态监控和诊断[JavaAgent
是运行在main
方法前的拦截器,可以认为先执行JavaAgent
的内定premain
方法再执行main
方法];使用ASM
实现对类的字节码进行操作,比如热更新和方法追踪[ASM
是Java
字节码操作和分析框架,可以修改现有类或者以二进制形式动态生成类,提供常见的字节码转换和分析算法用于构建复杂字节码转换和代码分析工具,ASM
被设计成小而快关注性能的字节码操作分析框架,非常适合在动态系统中使用,也支持在编译器中以静态方式使用];Arthas
的Telnet Client
代码源于Apache Commons Net
;Arthas
的profiler
命令基于async-profiler
实现用于性能分析和生成火焰图
官方文档:https://arthas.aliyun.com/zh-cn/
一般更习惯使用Arthas
对线上运行的Java
进程通过指令来进行监控
安装:
方式一:在linux
操作系统上通过命令wget https://alibaba.github.io/arthas/arthas-boot.jar
或者wget https://arthas.gitee.io/arthas-boot.jar
下载arthas-boot.jar
文件
通过java -jar arthas-boot.jar
命令启动项目,启动后Arthas
会检测当前服务器上的Java
进程并将这些进程以列表的形式展示出来,用户输入指定进程对应的编号并回车指定Arthas
要监控的java
进程
也可以通过java -jar arthas-boot.jar [pid]
启动Arthas
并通过进程号直接指定要监控的Java
进程
输入要监控的进程id
后如果是首次启动Arthas
还会自动联网下载相关依赖包
通过java -jar arthas-boot.jar -h
可以查看Arthas
启动命令的帮助文档
方式二:在浏览器直接点访问https://alibaba.github.io/arthas/arthas-boot.jar
下载arthas-boot.jar
到本地
卸载
linux
平台下使用命令rm -rf ~/.arthas/
以及rm -rf ~/logs/arthas
删除指定目录文件
Windows
平台直接删除user/home
目录下的.arthas
和logs/arthas
目录
Arthas
工程目录结构
模块名 | 功能 |
---|---|
agent | 基于JavaAgent 的代理模块用于 JVM 启动时加载Arthas 的代理代码实现对Java 应用的动态监控和诊断 |
arthas-agent-attach | 探针模块 用于将 agent 动态挂载到目标JVM 上 |
arthas-springboot-starter | 为SpringBoot 项目提供JVM 监听出口方便在SpringBoot 应用中集成Arthas |
arthas-vmtool | JVM 工具类模块,实现vmtool命令用于获取虚拟机实例信息 |
boot | Arthas 的启动入口模块,包含启动控制台的逻辑,如解析命令行参数、下载依赖组件,是Java 版本的意见安装启动脚本 |
client | Java 进程客户端连接模块用于建立客户端与服务端的连接,发送用户指令到服务端执行,并接收服务端的输出结果 |
common | 公共类 存放Arthas整合如IO、文件、反射相关的工具类和一些枚举类 |
core | 核心库 调用 Arthas 内部如client 、agent 、spy 等各个组件实现包括attach 宿主应用进程、加载arthas-agent 、实现核心命令以及与arthas-client 通信等核心功能 |
memorycompiler | 内存编译器模块,存放Arthas各类对象的编译信息类 |
packaging | maven 打包相关路径配置模块 |
site | 使用文档模块,存放Arthas 链接到JVM 进程的使用文档 |
spy | 增强接口模块,定义了接口,具体的实现在core 模块中,用于实现类似SpringAOP 的Advice ,有前置方法、后置方法等 |
tunnel-client | 客户端模块,用于管理客户端 |
tunnel-common | 存放与tunnel 相关的常量 |
tunnel-server | 服务端模块,用于管理服务端 |
tutorials | 存放一些问题记录和教程 |
web-ui | 存放Web Console 用到的前端资源。 |
Web Console
Arthas
连接要监控的Java
进程后,可以通过浏览器访问本机的8563
端口,会展示一个和控制台界面相似的控制台来让用户使用Arthas
命令
日志
通过命令cat ~/logs/arthas/arthas.log
可以查看Arthas
相关的日志文件
常用命令[以下命令都在Arthas
控制台终端使用]
quit/exit
:退出Arthas
控制台终端
基础指令:
命令 | 功能 |
---|---|
help | 显示Arthas 常用命令和命令的功能具体指令比如 reset -h 可以查看reset 命令的用法 |
cat | 打印文件内容 类似 linux 命令 |
echo | 打印参数 类似 linux 命令 |
grep | 匹配查找包含指定字符的内容 类似 linux 命令 |
tee | 将标准输入的数据同时输出到标准输出设备如终端和指定文件中 类似 linux 命令 |
pwd | 返回当前工作目录 类似 linux 命令 |
cls | 清空当前控制台屏幕 |
session | 查看被监控的Java 进程和当前会话的sessionId |
reset | 将被Arthas 增强过的类全部还原重置Arthad 服务端关闭时也会重置所有被增强过的类 |
version | 打印当前使用Arthas 的版本号 |
history | 打印此前输入过的历史命令 |
quit/exit | 退出当前Arthas 客户端,但是其他Arthas 客户端不受影响 |
stop/shutdown | 关闭Arthad 服务端,所有Arthas 客户端全部退出 |
keymap | 显示Arthas 当前的快捷键映射表Arthas 可以通过在当前用户目录下创建$USER_HOME/.arthas/conf/inputrc 文件来自定义快捷键 |
JVM
相关常用命令
命令 | 功能 |
---|---|
dashboard | 展示实时数据面板 线程信息优先级、状态、CPU使用率等线程信息 堆和非堆内存信息和GC信息 JVM 的运行时环境每间隔一段时间就会打印一次数据面板,使用 Ctrl+C 停止打印回到Arthas 控制台dashboard -i 500 是指定实时数据面板打印时间间隔,单位是ms dashboard -n 4 是真顶实时数据面板的打印次数,单位是次 |
thread | 以列表的形式展示当前JVM 进程中线程的个数状态信息,默认按CPU使用率对线程进行排序thread 1 查看线程列表中编号为1 的线程的运行情况thread -b 查看JVM 进程中处于阻塞状态的线程有哪些thread -i 5000 统计指定5s 时间内所有线程对CPU 的使用率thread -n 2 显示线程列表中CPU占用率前两位的线程的详细信息 |
sysprop | 列举JVM 系统属性和属性值 |
sysenv | 列举JVM 的环境变量 |
getstatic | 查看类的静态属性 |
heapdump | heapdump /tmp/test.hprof 导出当前监控系统的堆转储文件heapdump --live /tmp/test.hprof 是只导出当前Java 进程活跃对象的堆转储文件 |
类和类加载器相关常用命令[以下命令的官方文档参考如https://arthas.aliyun.com/doc/jad
]
命令 | 功能 |
---|---|
sc | 查看JVM 中已加载的类信息-d 输出当前类的原始文件来源、类加载器等详细信息,如果一个类被多个类加载器加载会显示多次-E 开启正则表达式匹配,默认为通配符匹配-f 额外输出当前类的成员变量,需要和-d 参数一起使用-x 指定输出静态变量时对属性的遍历深度,默认为0 ,相当于直接使用toString 输出 |
sm | 查看已加载类的方法信息,无法查看父类声明的方法sm 全类名 会展示指定类声明的全部方法sm 全类名 方法名 会展示对应方法,形参列表和返回值类型-d 展示每个方法的详细信息-E 开启正则表达式匹配,默认为通配符匹配 |
jad | 反编译指定已加载类的源码jad java.lang.String 能输出反编译后的String 类的Java 源码jad 全类名 方法名 能输出反编译后的对应方法的Java 源码 |
mc | 内存编译器,能将java 文件编译为字节码文件,通常和redefine 一起使用来替换掉JVM 已加载的类mc /tmp/Test.java 编译Test.java 生成对应字节码文件,会显示编译后的字节码文件所在目录 |
redefine | 替换掉JVM 中相同类名的类redefine /root/IdeaProjects/MyDemo/HelloWorld.class 加载新字节码文件替换掉JVM 进程中的同名类官方建议使用 retransform 命令替代redefine 命令 |
classloader | 显示当前JVM 进程中的类加载器、类加载器个数和对应加载了多少个类-t 以继承树的形式展示所有类加载器的继承关系-l 按类加载器实例查看类加载器的统计信息-c 类加载器hashCode 使用类加载器对应hashCode 查看该类加载器能加载哪些jar 包的字节码 |
方法相关命令
命令 | 功能 |
---|---|
monitor | 方法执行监控,这是非实时返回命令,不是方法执行结束立即就能更新统计结果,每个统计周期打印一次 可以针对每个类的所有方法 monitor 全类名 或者指定方法monitor 全类名 方法名 对方法的调用情况进行监控,统计方法的调用次数、执行时间和失败率等数据-c 设置方法执行数据的统计周期,单位是秒,默认是120s |
watch | 对方法执行数据如返回值、入参、抛出异常进行观测,通过OGNL 表达式查看指定变量watch 全类名 方法名 显示指定方法每次调用的执行时刻、花费时间、方法执行信息中的result 默认包含入参值、和返回值watch 全类名 方法名 "{params,returnObj}" -x 2 方法执行信息中的result 只包含入参值和返回值-x 指定输出结果的属性的遍历深度,默认深度为1 |
trace | 显示方法内部方法的调用路径,并输出路径上每个方法调用节点的耗时trace 全类名 方法名 显示方法每次调用的时刻、被调用的线程名、线程id、是否为守护线程、线程优先级、加载当前类的类加载器、方法的执行耗时-n 只输出指定数量条方法调用记录 |
stack | 输出当前方法的被调用路径stack 全类名 方法名 显示方法每次调用的时刻、被调用的线程名、线程id、是否为守护线程、线程优先级、加载当前类的类加载器以及方法在Java 源码中被调用的位置-n 只输出指定数量条方法调用记录 |
tt | tt 是TimeTunnel 的缩写,记录指定方法每次调用的入参和返回值信息-t 表明希望记录每次指定方法的执行情况-n 3 只输出指定数量条方法调用记录tt -t 全类名 方法名 显示方法调用时刻、花费时间、方法所属类信息 |
火焰图相关
profiler
命令生成火焰图,包含启动profiler
、获取样例、查看profiler
状态、停止profiler
,生成svg
格式或者html
格式的火焰图
生成火焰图需要先通过命令profiler start
启动profiler
;通过profiler getSamples
命令获取样例数据,该命令返回获取的样例数据个数;通过profiler status
查看profiler
的执行状态;通过命令profiler stop --file /tmp/output.svg
指定火焰图的生成路径生成火焰图并停止profiler
,默认生成的就是svg
格式的火焰图,没有设置生成位置默认会将火焰图生成在目录/tmp/demo/arthas-output/xxx.svg
;可以通过命令profiler stop --format html
指定生成的火 焰图格式为html
火焰图支持从浏览器地址http://localhost:3658/arthas-output/
访问3658
端口访问,会显示此前生成的所有火焰图
Arthas
设置相关
options
命令用于查看或者设置Arthas
全局开关
等等[Arthas
的命令还有相当多,作为一个性能监控工具学习成本也有亿点点高]
JMC
[Java Mission Control
]
介绍:JDK
从JDK7u40
开始在bin
目录下提供了JMC
工具,JFR
[Java Flight Recorder]飞行记录仪是JMC
中的一部分,JFR
从JDK11
开始开源,此前属于商业版特性,JFR
功能需要使用JVM
参数-XX:+UnlockCommercialFeatures
开启
官方文档:https://github.com/JDKMission Control/jmc
直接点击jmc.exe
就能打开jmc
客户端可视化界面
JMC
用于对Java
进程的管理、监视、概要分析和故障排查,包含一个GUI
客户端和众多收集Java
虚拟机性能数据的插件,比如访问虚拟机各子系统运行数据的MXBeans
的JMX Console
以及profiling
工具JFR
,JMC
采用取样而非代码植入的方式采集数据,对被监控系统性能的影响非常小,完全可以开着JMC
做压测,唯一可能对JVM
进程造成的影响是Full GC
变多了
如果连接的是远程JVM
进程,远程JVM
进程需要使用下列参数开启JMX
,在JMC
客户端点击文件--连接--创建新连接填入JMX
参数中的host
和port
xxxxxxxxxx
-Dcom.sun.management.jmxremote.port=${YOUR PORT}
-Dcom.sun.management.jmxremote
-Dcom.sun.management.jmxremote.authenticate=false
-Dcom.sun.management.jmxremote.ssl=false
-Djava.rmi.server.hostname=${YOUR HOST/IP}
MBean
服务器
概览面板
默认会实时记录展示Java
堆内存占用、CPU
使用率,用户可以自定义仪表盘要展示的各种数据
展示机器的CPU
使用率和JVM
进程的CPU
使用率的折线图、内存占用的折线图,都可以自定义展示要观测数据
触发器面板
可以设置触发器在CPU占用过低过高、发生死锁、线程数量太多时都可以触发报警
内存
展示各个内存区域的详细信息,GC
信息
线程
展示JVM
中的所有线程状态、CPU占用率等线程信息
飞行记录器
JFR
会监控显示JVM
进程的系统属性、内存信息、GC信息;代码面板下能看到热点包、热点类、热点方法、调用树、异常错误等;线程面板展示线程和线程状态
飞行记录器的设置固定时间记录最好大于等于1min
,事件设置最好选择Continuous
[Profiling会占用更多的服务器资源]
飞行记录器的配置方法用到时再检索重新总结,老师讲的太简陋
使用JFR
除了要使用JVM
参数-XX:+UnlockCommercialFeatures
,还要增加JVM
参数-XX:+FlightRecorder
,否则启动JFR
会报错
JFR
能以极低性能开销收集Java
虚拟机的性能数据,默认配置下性能开销平均低于1%
飞行记录器会记录运行过程中发生的一系列事件,包含java
层面的线程、锁事件,JVM
内部的新建对象、垃圾回收和即时编译事件,JFR
将这些事件分为四种类型
瞬时事件:比如是否出现异常、线程启动这种发生与否的事件
持续事件:比如垃圾回收这种持续一段时间的事件
计时事件:时长超出指定阈值的持续事件
取样事件:周期性取样的事件,比如方法取样,每隔一段时间统计每个线程的栈轨迹,检查栈轨迹中是否存在一个反复出现的方法
启动方式
方式一:运行目标Java
程序时添加参数如java -XX:StartFlightRecording=delay=5s,duration=20s,filename=myrecording.jfr,settings=profile MyApp
,执行上述命令JVM
启动5s
[delay=5s
]后JFR
开始收集数据,持续20s
[duration=20s
],收集玩数据后,JFR
会将收集的数据保存在指定文件中[filename=myrecording.jfr
]
如果不对JFR
持续收集数据的过程加以限制,JFR
可能会填满硬盘所有空间,可以在命令中添加如下参数对收集的数据进行限制java -XX:StartFlightRecording=delay=5s,duration=20s,filename=myrecording.jfr,settings=profile,maxage=10m,maxsize=100m,name=SomeLabel MyApp
,其中maxage=10m
表示超过10
分钟JFR
就不工作了,maxSize
表示数据文件超过100m
就不收集数据了,maxage
和maxSize
只要有一个条件达成都不会再收集数据
方式二:使用jcmd
的JFR
子命令来启动关闭JFR
以及使用JFR
收集数据
jcmd <PID> JFR.start settings=profile maxage=10m maxsize=150m name=SomeLabel
启动JFR
,注意此时JFR
已经开始收集数据
使用命令jcmd <PID> JFR.dump name=SomeLabel filename=myrecording.jfr
导出已经收集的数据
使用命令jcmd <PID> JFR.stop name=SomeLabel
关闭目标进程中的JFR
方式三:在JMC
的图形化界面的Harness jython
中右键FilghtRecorder
选择Start Flight Recording
直接启动
其他工具
Flame Graphs
火焰图:
介绍:火焰图是直观展示CPU在程序整个生命周期中时间分配的工具,可以直观第显示调用栈的CPU消耗瓶颈,一般用于查找接口的性能瓶颈
Java
火焰图的网上大部分讲解来自于Brendan Gregg
的博客http://www.brendangregg.com/flamegraphs.html
,需要用到火焰图的时候可以看这个人的博客进行学习
火焰图的x
坐标是时间,看火焰图重点是关注某一层调用栈时间上的占比,每一个小框表示一个栈帧,纵轴表示一个虚拟机栈
Tprofiler
:
介绍:阿里提供的开源工具TProfiler
可以定位对性能影响很大的代码
一般系统的性能瓶颈包括创建了过多的静态对象、大量业务线程频繁创建一些生命周期很长的临时对象
TProfiler
性能也很强大,目前受欢迎程序越来越广
TProfiler
最重要的特性是能统计出指定时间段内JVM
的top method
[热点方法],这些top method
极有可能就是造成JVM
性能瓶颈的元凶,这是绝大多数JVM
调优工具不具备的功能,JRockit
的首席开发者Marcus Hirt
在私人博客《Low Overhead Method Profiling with Java Mission Control
》明确指出JRMC
不支持TOP Method
的统计
下载地址:https://github.com/alibaba/TProfiler
BTrace
是SUN Kenai
云计算开发平台下的开源项目,用于动态追踪Java
进程的工具,也会将性能监控代码注入到JVM
的字节码来采集程序运行数据
YourKit
JProbe
Spring Insight
:基于Spring
开发的系统可以尝试使用Spring Insight
内存溢出分析
概念:为对象分配内存空间时即使在Full GC
后仍然没有足够的内存可以使用就叫内存溢出
大规模请求场景下,tomcat
可能因为无法承受请求压力而发生内存溢出错误
在堆空间较小的情况下,tomcat
标准管理器中ConcurrentHashMap
类型的sessions
属性,该属性中保存了每个用户的session
数据,通过OQL
语句可以查询session
个数,计算session
占用堆空间的比例来判断是否是短时间内用户数量太多导致session
占用内存太大造成的内存溢出,我们还可以对每个session
的创建时间做统计,计算不同百分比session
的创建速度从而检测判断内存溢出是否由短时间大量创建用户Session
导致
我们可以直接从MAT
分析dump
文件排查这些可疑的内存泄漏或者内存溢出点
内存泄漏分析
内存泄漏概念:对象已经不再使用,但是可达性分析算法判断对象仍然可触及,JVM
误以为该对象仍然在使用中无法被回收造成内存泄漏;典型就是一个对象的可支配树子树已经不再需要被使用了,但是仍然被其他对象或者类变量直接或者间接引用
严格意义上的内存泄露就是上面已经不会再被程序使用,但是GC
又无法回收他们
宽泛意义上内存泄漏指开发者的一些不好的实践或者疏忽导致对象的生命周期不必要地变得很长导致内存紧张甚至造成内存溢出
像TLAB
中总有一部分内存无法被使用也可以看成是一种内存泄漏
内存泄漏的增多最终会导致内存溢出
注意像while(true){}
循环体中定义的局部变量并且没有发生逃逸,每执行完一次循环这些局部变量都会变成垃圾可以被回收
内存泄露的分类
经常发生:每次执行一段代码都会导致一块内存的泄露
偶然发生:在特定情况下才会发生,比如数据库连接、IO流等资源没有被正确关闭,比如关闭代码不在finally
块中但是出现了异常无法执行资源关闭代码
一次性:发生内存泄漏的方法只会被执行一次
隐式泄露:一直占着内存不释放直到进程运行结束
内存泄漏的八种情况
静态集合类容器比如hashMap
、LinkedList
的生命周期和JVM
进程一致,容器中的对象不手动移除在JVM
进程结束前不能被释放从而导致内存泄漏,但是容器中的部分对象永远也不会再被程序使用;这是短生命周期对象被长生命周期对象持有导致的内存泄漏
单例模式,单例对象一般也是静态的,生命周期和JVM
进程一样长,单例对象如果持有外部对象的引用,外部对象也不会被回收造成内存泄漏
内部类持有外部类,一个外部类实例的方法返回一个内部类实例,该方法返回的内部类实例被一个长生命周期对象持有,即使外部类实例对象不再被程序使用,该外部类实例也无法被垃圾回收造成内存泄漏
数据库连接、网络IO流、本地IO流这种连接资源不再使用时都需要手动调用资源对象的close
方法释放连接,只有连接被关闭后垃圾收集器才会回收对应的资源对象,访问数据库如果Connection
、Statement
、ResultSet
不显示关闭会造成大量对象无法被回收引起内存泄漏
变量不合理的作用域,一个变量定义的作用范围大于变量的使用范围,且没有及时被置为null
,很有可能导致内存泄漏,比如一个对象只在某个方法中被使用,只需要设置成局部变量,但是却被设置成成员变量,在方法中被创建然后赋值给成员变量,方法执行结束后也没有将成员变量置为null
造成内存泄漏
对对象的修改改变了对象的哈希值,一个存入哈希表结构集合的对象,如果对象中有字段参与了哈希值的计算,而我们又修改了这些字段,会导致对象的当前哈希值和对象存储在集合中的位置不匹配,即使是使用contains
方法使用当前对象的引用作为参数去hashSet
集合中检索对象也无法找到该对象,这也会导致无法从HashSet
集合中单独删除该对象造成内存泄漏[因为删除页需要根据对象的当前哈希值去找节点位置]
而且这种情况会导致hashSet
中存放两个相同的节点指向同一个对象,情节比较恶劣
默认的对象的hashCode
方法继承自Object
类,通过对象的内存地址计算哈希值,但是这种方式存在局限性,因为通常两个对象的属性值完全相同我们就会认为两个对象相同,因此一般重写hashCode
方法,而且集合本身也依赖hashCode()
和equals
方法来区分对象,而且重写hashCode
方法必须同时重写equals
方法,因为要保证两个对象equals
方法认为相等,那么hashCode
方法得到的哈希值也必须相等;因此使用集合管理对象经常会考虑两个对象属性值完全相同就相同,并且同时重写equals
和hashCode
方法,默认重写的hashCode
就是会根据属性值计算哈希值,此时就要特别注意,一旦重写了HashCode
且已经被集合管理的对象,只要对集合中的对象修改了属性[这是非常常见的操作],这个对象就无法删除也无法再从集合中找到,因此查找和删除都会使用对象当前的哈希值,但是对象的存储位置还是使用的旧哈希值[但是感觉哈希表扩容的时候会自动修正啊]
此外重写了hashCode
并已经使用集合进行管理的对象,一旦对属性进行了修改,再次向HashSet
中添加属性完全相同的对象仍然能够正常添加,这是因为新的哈希值对应桶上不一定有相同对象;此外采用最初的属性值的对象添加到集合中发现哈希值对应位置上已经有元素[节点中好像会保存对象的哈希值,不保存的话比较哈希值也不一样,但是不影响也会直接覆盖]但是调用equals
方法比较两个对象时不等会直接将修改了属性的对象直接覆盖掉,如果没有对修改属性的对象重新保存,旧的被修改的对象就直接丢失了[lombok
的@Data
注解会重写getter
、setter
、toString
、equals
、hashCode
、无参构造器和全参构造器,因此使用了该注解的对象在使用集合管理时千万不要修改集合中对象的属性,改了集合中的对象就找不到也删不了,如果没有额外保存一份再添加旧属性完全相同的对象时会直接将修改后的对象直接覆盖掉]
像String
类被设置成不可变类型就不会存在修改属性值会导致hashCode
变化的问题,可以放心地使用HashSet
或者hashMap
来进行管理
缓存导致的内存泄露,放入缓存的对象很容易被遗忘,可能会导致项目启动非常慢并且发生OOM
,因为测试环境的缓存数据量一般都不大,但是生产环境缓存的数据量不好控制,缓存的数据量可能非常大导致数据加载时间长,内存占用过大造成内存泄漏
可以对缓存对象使用弱引用,使用WeakHashMap
管理缓存数据,除了缓存集合没有其他非软引用指向缓存对象,缓存集合会自动丢弃缓存让缓存对象被回收;注意WeakHashMap
只有key
使用的是弱引用,值使用的是强引用,因此WeakHashMap
中的对象除了集合本身对key
的弱引用外,key
对应对象没有其他引用,集合会自动丢弃该键值对让键值对对应对象自动被垃圾回收
监听器和回调引起的内存泄漏
客户端在系统监听器API
中注册回调,但是没有显示取消,回调对象就会累积,比较好的方案还是将回调对象保存为WeakHashMap
的键,用缓存集合使用软引用来指向回调对象让其不被使用时自动被回收
过期引用
弹栈操作只是将指针指向了栈顶元素的下一个元素,栈中被弹出的元素并没有被置空,栈中仍然保存已弹栈对象的引用,这种情况就称为过期引用,过期引用导致的内存泄漏问题比较隐蔽
常见JVM
参数
在oracle
官网https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html
提供了对各种参数的说明文档,整个文档的参数有六百多个
自己总结的常用JVM
参数
-XX:+PrintFlagsInitial
:打印所有JVM
参数的默认初始值
-XX:+PrintFlagsFinal
:打印所有JVM
参数的实际值,:=
符号表示实际值不等于默认初始值
jinfo -flag 参数名 进程号
:打印指定JVM
进程中指定参数名的实际参数值
比如使用jinfo -flag UseParallelGC 924
查看JVM
是否使用Parallel GC
,返回的结果是-XX:+UseParallelGC
表示正在使用Parallel
;同理对Parallel Old
有jinfo -flag UseParallelOldGC 924
,结果为-XX:+UseParallelOldGC
,如果没有使用指定垃圾回收器会返回如-XX:-UseParallelGC
-Xms
:设置堆空间的初始内存,默认物理内存的1/64
-Xmx
:设置堆空间的最大内存,默认物理内存的1/4
-Xmn
:设置新生代的内存
-XX:NewRatio
:设置老年代容量是新生代容量的多少倍
-XX:SurvivorRatio
:设置新生代中伊甸园区容量是单个幸存者区容量的多少倍
幸存者区太小会导致很多对象不经过多次YGC
就直接进入老年代,让分代设计和YGC
失去效果
如果伊甸园区设置的太小会导致频繁地发生YGC
频繁STW
影响系统性能
-XX:MaxTenuringThreshold
:设置新生代对象的年龄计数器晋升老年代的计数阈值
-XX:+PrintGCDetails
:打印详细的GC
处理日志和堆空间内存情况,不同的GC
打印信息不同,参数优先级比下面一个参数更高,显示的内容依次为GC
类型、GC
原因、新生代使用的垃圾收集器和新生代占用内存前后变化、老年代使用GC
和老年代占用内存GC
前后变化、整个堆占用内存GC
前后变化、方法区占用内存GC
前后变化、GC
持续时间;Times
中user
是垃圾收集器花费的所有CPU
时间[用户态用时,不包含其他进程的执行时间即垃圾回收线程阻塞的时间]、sys
是花费在等待系统调用或者系统事件的时间[系统内核态用时]、real
是从GC
开始到GC
结束的整个持续时间[包含和其他线程抢夺CPU时间片以及等待的时间,一般real
会小于user
+sys
,这是因为现在一般使用的都是多核CPU,如果real
>user
+sys
说明IO负载比较重或者CPU核数不够用]
Serial
显示新生代名字为[DefNew
;ParNew
显示新生代名字为[ParNew
;Parallel
显示新生代名字为[PSYoungGen
;Parallel Old
显示老年代的名字为[ParOldGen
;G1
显示garbage-first heap
Allocation Failure
:表示本次GC
是由于没有足够空间存储新数据产生的
堆信息依次显示新生代def new generation
;老年代tenured generation
;永久代compacting perm gen
;元空间MetaSpace
的最大可用内存和已使用的内存,还会显示每个区域的虚拟内存开始和结束的地址
-XX:+PrintGC
/-verbose:gc
:打印简要的GC
日志信息,不会打印堆空间的内存情况,显示的内容依次为GC
类型、GC
原因、GC
前后可用内存变化以及堆区总内存,GC
持续的时间
-XX:+PrintGCTimeStamps
:以基准时间的格式输出GC
的时间戳
在GC
日志前面加上GC
发生时距离JVM
启动后的以秒为单位的时间长度
-XX:+PrintGCDateStamps
:以日期时间的格式输出GC
的时间戳
在GC
日志前面加上GC
发生时的实际带时区时间和日期,一般使用日志分析工具会加上GC
的时间戳
-XX:+PrintHeapAtGC
:在GC
前后打印堆的信息
-Xloggc:./logs/gc.log
:把GC
日志输出到指定路径的文件中
.
表示当前路径,这个当前路径是指当前工程的根目录,logs
目录必须提前创建,否则JVM
启动会报错
常用的GC
日志分析工具有GCViewer
、GCEasy
、GCHisto
、GCLogViewer
、Hpjmeter
、garbagecat
GCViewer
:github
上能下载jar
包,这个jar
包双击就能运行,直接导入GC
日志文件就行
GCEasy
:官网gceasy.io
,直接把日志文件上传点击分析就能在线分析日志信息,GCEasy
的信息显示的相对来说更全面一些
-XX:HandlePromotionFailure
:是否设置空间分配担保
发生YGC
前,JVM
会检查老年代的最大可用连续空间是否大于新生代所有对象的总空间,如果大于说明本次YGC
是安全的,如果小于则说明本次YGC
存在风险,JVM
会检查系统参数HandlePromotionFailure
的设置值是否允许担保失败
如果HandlePromotionFailure=true
表示允许担保失败,JVM
会检查老年代连续可用空间是否大于历次晋升到老年代的对象的平均大小,如果老年代大于平均值,就尝试一次YGC
,但是本次YGC
还是有风险的;如果老年代小于平均值,说明本次YGC
出事的概率很大,此时将YGC
改为进行一次Full GC
如果HandlePromotionFailure=false
表示不允许担保失败,JVM
检查到老年代的最大可用连续空间小于新生代对象占用的总空间会直接进行Full GC
,不会再去检查老年代内存是否大于历次晋升老年代对象的平均大小从而发起YGC
的尝试
在JDK7
及以后,该HandlePromotionFailure
就失效了,不会再影响虚拟机的空间分配担保策略,默认就是允许担保失败;JDK6 Update24
版本以后OpenJDK
的源码中已经不使用该参数而是直接使用该参数为true
的规则,只要YGC
前老年代的连续可用空间大于新生代对象总大小或者大于历次晋升对象的平均大小就会进行YGC
,否则将YGC
改为Full GC
-XX:+PrintEscapeAnalysis
查看逃逸分析的筛选结果
-XX:+PrintCommandLineFlags
:在控制台打印JVM
参数,包含了JVM
使用的垃圾回收器参数如默认的-XX:+UseParallelGC
,JVM
在确认新生代使用Parallel
后老年代会自动使用Parallel Old
-XX:+TraceClassLoading
:配置该JVM
参数可以追踪打印类的加载信息,该参数会打印所有加载过的类的日志,没啥代码的情况下也会打印一千行左右
JVM
运行时参数
JVM
参数选项类型:
1️⃣标准参数选项:
-X
和-XX
参数选项都是非标准参数
特点是稳定,基本不会随着JDK
版本迭代发生变化,形式上以-
打头,可以通过java -h
输出的就是标准参数,标准参数开发者一般使用的比较少
32
位windows
操作系统上必须保证至少两个以上CPU
和2G
以上物理内存才能使用Server
模式,64
位操作系统只支持Server
模式
Client
模式使用C1
编译器对字节码的优化比较简单编译耗时短,优化方式只考虑方法内联、去虚拟化、冗余消除
Server
模式使用C2
编译器对字节码的优化比较激进,编译耗时长,代码运行更高效,除了考虑方法内联、去虚拟化、冗余消除以外还支持逃逸分析,基于逃逸分析做标量替换、栈上分配、同步消除
xxxxxxxxxx
C:\Windows\system32>java -h
用法: java [-options] class [args...]
(执行类)
或 java [-options] -jar jarfile [args...]
(执行 jar 文件)
其中选项包括:
-d32 使用 32 位数据模型 (如果可用)
-d64 使用 64 位数据模型 (如果可用)
-server 选择 "server" VM
默认 VM 是 server.
-cp <目录和 zip/jar 文件的类搜索路径>
-classpath <目录和 zip/jar 文件的类搜索路径>
用 ; 分隔的目录, JAR 档案
和 ZIP 档案列表, 用于搜索类文件。
-D<名称>=<值>
设置系统属性
-verbose:[class|gc|jni]
启用详细输出
-version 输出产品版本并退出
-version:<值>
警告: 此功能已过时, 将在
未来发行版中删除。
需要指定的版本才能运行
-showversion 输出产品版本并继续
-jre-restrict-search | -no-jre-restrict-search
警告: 此功能已过时, 将在
未来发行版中删除。
在版本搜索中包括/排除用户专用 JRE
-? -help 输出此帮助消息
-X 输出非标准选项的帮助
-ea[:<packagename>...|:<classname>]
-enableassertions[:<packagename>...|:<classname>]
按指定的粒度启用断言
-da[:<packagename>...|:<classname>]
-disableassertions[:<packagename>...|:<classname>]
禁用具有指定粒度的断言
-esa | -enablesystemassertions
启用系统断言
-dsa | -disablesystemassertions
禁用系统断言
-agentlib:<libname>[=<选项>]
加载本机代理库 <libname>, 例如 -agentlib:hprof
另请参阅 -agentlib:jdwp=help 和 -agentlib:hprof=help
-agentpath:<pathname>[=<选项>]
按完整路径名加载本机代理库
-javaagent:<jarpath>[=<选项>]
加载 Java 编程语言代理, 请参阅 java.lang.instrument
-splash:<imagepath>
使用指定的图像显示启动屏幕
有关详细信息, 请参阅 http://www.oracle.com/technetwork/java/javase/documentation/index.html。
2️⃣-X
参数选项:
特点也是相对比较稳定,基本不会随着JDK
版本迭代发生修改或者弃用,相对来说-XX
的变化可能非常大,形式上以-x
打头,可以通过命令java -X
打印具体参数
-Xmixed
:默认JVM
就是混合模式,混合模式是JVM
的即时编译器和解释器是同时协同工作[解释器程序启动快,只针对热点代码的超过热点代码执行频率超过判断阈值编译成本地代码缓存],如果我们希望程序运行只使用解释器可以配置命令参数-Xint
禁用掉即时编译器[所有的字节码都需要被解释执行],如果我们希望程序运行只使用即时编译器可以配置命令参数-Xcomp
禁用掉解释器[所有方法字节码第一次使用都一次性被编译成本地代码生成缓存,然后再直接调用缓存执行];混合模式是现代的主流模式,语言的执行效率主要看编译器的设计
-Xms<size>
:设置堆内存初始大小,等价于JVM
参数-XX:InitialHeapSize
;-Xmx<size>
:设置堆内存最大大小,等价于JVM
参数-XX:MaxHeapSize
;-Xss<size>
:设置虚拟机栈大小,等价于JVM
参数-XX:ThreadStackSize
;
<size>
表示指定参数时需要填写具体的参数值
xxxxxxxxxx
C:\Windows\system32>java -X
-Xmixed 混合模式执行 (默认)
-Xint 仅解释模式执行
-Xbootclasspath:<用 ; 分隔的目录和 zip/jar 文件>
设置搜索路径以引导类和资源
-Xbootclasspath/a:<用 ; 分隔的目录和 zip/jar 文件>
附加在引导类路径末尾
-Xbootclasspath/p:<用 ; 分隔的目录和 zip/jar 文件>
置于引导类路径之前
-Xdiag 显示附加诊断消息
-Xnoclassgc 禁用类垃圾收集
-Xincgc 启用增量垃圾收集
-Xloggc:<file> 将 GC 状态记录在文件中 (带时间戳)
-Xbatch 禁用后台编译
-Xms<size> 设置初始 Java 堆大小
-Xmx<size> 设置最大 Java 堆大小
-Xss<size> 设置 Java 线程堆栈大小
-Xprof 输出 cpu 配置文件数据
-Xfuture 启用最严格的检查, 预期将来的默认值
-Xrs 减少 Java/VM 对操作系统信号的使用 (请参阅文档)
-Xcheck:jni 对 JNI 函数执行其他检查
-Xshare:off 不尝试使用共享类数据
-Xshare:auto 在可能的情况下使用共享类数据 (默认)
-Xshare:on 要求使用共享类数据, 否则将失败。
-XshowSettings 显示所有设置并继续
-XshowSettings:all
显示所有设置并继续
-XshowSettings:vm 显示所有与 vm 相关的设置并继续
-XshowSettings:properties
显示所有属性设置并继续
-XshowSettings:locale
显示所有与区域设置相关的设置并继续
-X 选项是非标准选项, 如有更改, 恕不另行通知。
3️⃣-XX
参数选项:
一般开发者使用该参数比较多,参数数量也是最多的,这类参数是实验性的,可能随着版本迭代发生修改或者移除[比如垃圾收集器CMS
被废弃对应的JVM
参数也会废弃],形式上以-XX
打头,主要作用是为了开发和调试JVM
这些类型参数可以分成两种格式
Boolean
类型格式:-XX:+<option>
或者-XX:-<option>
表示启用或者禁用option
属性,默认情况下有很多禁用或者启用的配置
比如-XX:-UseParallelGC
、-XX:+UseG1GC
、-XX:+UseAdaptiveSizePolicy
[自动调整伊甸园区和幸存者区的比例以满足最低暂停时间和控制GC
频率的目的,比手动显示指定伊甸园区和幸存者区的比例更好]
非Boolean
的k-v
类型
数值类型-XX:<option>=<number>
-XX:NewSize=1024m
:设置新生代初始大小为1024M
-XX:MaxGCPauseMillis=500
:设置GC
暂停时间为500
ms
-XX:GCTimeRatio=19
:设置垃圾收集器的吞吐量
-XX:NewRatio=2
:设置新生代和老年代的比例
非数值类型-XX:<name>=<string>
-XX:HeapDumpPath=/usr/local/heapdump.hprof
指定导出的堆转储文件的存储路径
特别JVM
参数
-XX:+PrintFlagsFinal
:输出所有参数的名称和实际值,默认不包含诊断性和试验性质的参数,可以配合-XX:+UnlockDiagnosticVMOptions
和-XX:+UnlockExperimentalVMOptions
输出诊断性和实验性的参数实际值,=
表示实际值采用默认值,:=
表示实际值不是默认值,实际值可能是JVM
根据实际情况修改的,也可能是用户指定的
添加JVM
参数的方式
Eclipse
: 右键@Test
--Run As
--Run Configurations
--在Arguments
面板的Program arguments
传递main
方法的形参,在VM arguments
传递JVM
参数
IDEA
:Run
--Edit Configuration
--在VM options
设置JVM
参数
运行jar
包:java -Xms50m -Xmx50m -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -jar demo.jar
通过Tomcat
运行war
包,linux
系统下可以在tomcat/bin/catalina.sh
中添加JVM
参数JAVA_OPTS="-Xms512M -Xmx1024M"
,Windows
下可以在catalina.bat
中添加JVM
参数set "JAVA_OPTS=-Xms512M -Xmx1024M"
程序运行过程中可以使用jinfo
设置JVM
参数,jinfo -flag <name>=<value> <pid>
设置非Boolean
类型参数,jinfo -flag [+|-]<name> <pid>
设置Boolean
类型参数
注意jinfo
改堆内存大小不能使用-Xms
,也不能使用InitialHeapSize
,这是因为JVM
一旦启动有些参数是不能进行修改的,像堆初始大小和使用哪种垃圾收集器都是不能修改的,能修改的之前说过很少只有十几个,可以使用命令java -XX:+PrintFlagsFinal -version |grep manageble
查看被标记为manageable
参数
常用JVM
参数
打印XX
选项和值
-XX:+PrintCommandLineFlags
:在程序运行前打印用户手动设置或者JVM
自动设置的-XX
参数
-XX:+PrintFlagsInitial
:打印出所有-XX
参数的默认值
-XX:+PrintFlagsFinal
:打印出-XX
参数在运行时的实际值
-XX:+PrintVMOptions
:打印JVM
参数
此外还可以使用jinfo
命令查看具体某一个JVM
参数值
虚拟机栈
-Xss128k
:设置每隔线程对应虚拟机栈的大小为128k
,等价于-XX:ThreadStackSize=128k
堆内存
-Xms3550m
:设置JVM
堆内存初始大小为3550MB
,等价于-XX:InitialHeapSize=3550m
-Xmx3550m
:设置JVM
堆内存最大大小为3550MB
,等价于-XX:MaxHeapSize=3550m
-Xmn2g
:设置年轻代的大小为2G
,官方推荐配置为整个堆大小的3/8
,等价于-XX:newSize=2g
,该参数会同时设置新生代初始大小和最大大小都为2G
,
默认情况下新生代占整个堆区的1/3
,老年代占整个堆区的2/3
[这个比例和实际情况是一样的]
伊甸园区占新生代的8/10
,单个幸存者区占新生代的1/10
[实际的比例为6:1:1
,与默认的8:1:1
不同,通过打印对应的JVM
参数-XX:SurvivorRatio
发现实际值确实是8
,默认情况下-XX:+UseAdaptiveSizePolicy
自动配置各个区的大小即自适应内存大小策略是开启的,但是关闭以后伊甸园区和幸存者区还是6:1:1
,要想强制设置伊甸园区和幸存者区的比例为8:1:1
必须通过设置参数-XX:SurvivorRatio=8
显示指定,在设置了-XX:SurvivorRatio=8
的情况下即使设置了-XX:+UseAdaptiveSizePolicy
也会按照-XX:SurvivorRatio=8
来分配伊甸园区和幸存者区的比例,建议让JVM
自动调整来尽可能满足垃圾收集的暂停时间指标]
-XX:NewSize=1024m
:设置年轻代的初始内存大小为1024MB
-XX:MaxNewSize=1024m
:设置年轻代的最大内存大小为1024M
-XX:SurvivorRatio=8
:设置伊甸园区域一个幸存者区的比值,默认为8
-XX:+UseAdaptiveSizePolicy
:JVM
自动设置各个内存区域的大小比例
-XX:NewRatio=4
:设置老年代相较于年轻代的内存大小比例,默认值是2,即老年代大小为新生代的两倍
-XX:PretenureSizeThreadshold=1024
:默认单位为字节,设置让大于此阈值的对象直接分配在老年代,该参数只对Serial
、ParNew
有效
-XX:MaxTenuringThreshold=15
:默认值为15
,设置幸存者区对象晋升老年代的存活年龄阈值,新生代每次MinorGC
后存活对象的年龄都会+1
-XX:+PrintTenuringDistribution
:JVM
每次MinorGC
后都打印当前正在使用中的Survivor
中的对象年龄分布
-XX:TargetSurvivorRatio
:设置minorGC
结束后幸存者区中占用空间的期望比例
永久代
-XX:PermSize=256m
:设置永久代初始内存为256MB
-XX:MaxPermSize=256m
:设置永久代最大内存为256MB
元空间
-XX:MetaspaceSize
:设置元空间初始内存大小
-XX:MaxMetaspaceSize
:设置元空间最大内存大小
-XX:+UseCompressedOops
:启用压缩对象指针
-XX:+UseCompressedClassPointers
:启用压缩类指针
-XX:CompressedClassSpaceSize
:设置Klass metaspace
的大小,默认是1G
直接内存
-XX:MaxDirectMemorySize
:设置直接内存的大小,默认与Java
堆内存最大大小一致
注意这里的直接内存大小不包括元空间,是JVM
能通过比如NIO
访问的直接内存大小,老师后来提到元数据区、直接内存是本地内存中互斥的两个部分,JVM
进程占用的内存为堆内存加上元空间加上直接内存的总和
OOM
相关JVM
参数
-XX:+HeapDumpOnOutOfMemoryError
:当内存出现OOM
时,自动生成堆转储dump
文件方便后续分析,默认未开启
生成dump
文件用于分析导致OOM
的原因
-XX:+HeapDumpBeforeFullGC
:在每次FullGC
以前自动生成堆转储dump
文件,默认未开启
生成dump
文件用于分析Full GC
发生的原因
-XX:HeapDumpPath=<path>
:指定堆转储文件的存储路径和文件名
默认是JVM
的工作目录下,工作目录指java
启动命令所在目录,或者代码System.getProperty("user.dir")
的返回值也是工作目录,如果生成的hprof
文件的名字相同会在文件后缀后加.1
、.2
...
-XX:OnOutOfMemoryError
:指定一个可执行程序或者脚本的路径,发生OOM
时自动执行该脚本
这是对OOM
的运维处理,一般针对jar
包的启动脚本中添加JVM
参数-XX:OnOutOfMemoryError=/opt/Server/restart.sh
,比如在发生OOM
的时候执行shell
脚本让服务重启
[linux
下restart.sh
示例]
xxxxxxxxxx
pid=$(ps -ef|grep Server.jar|awk '{if($8=="java"){print $2}}')
kill -9 $pid
cd /opt/Server/;sh run.sh
[Windows
下restart.sh
示例]
xxxxxxxxxx
echo off
wmic process where Name='java.exe' delete
cd D:\Server
start run.bat
垃圾收集器相关
-XX:+PrintCommandLineFlags
:查看命令行相关参数[结果的最后会显示使用了哪种垃圾收集器]
使用jinfo -flag 指定垃圾收集器参数 <PID>
也能查看指定垃圾收集器是否正在被使用
Serial
Client
模式下的默认新生代垃圾收集器,Serial Old
是Client
模式下默认的老年代垃圾收集器,可以获取最高的单线程垃圾收集效率,32位操作系统CPU核数大于等于2,内存大于两个G,建议切换成Server
模式;作为单核场景下的垃圾收集器,用户线程和垃圾收集线程肯定不能并发工作,像Web
这种服务端客户端交互性很强的场景只能使用并行或者并发垃圾收集器,不能使用Serial
-XX:+UseSerialGC
:指定新生代和老年代都使用串行收集器
ParNew
ParNew
和Serial Old
搭配在JDK8
过时,在JDK9
彻底废弃了该搭配;即JDK8
以后,ParNew
就只能和CMS
搭配使用,Serial Old
作为CMS
的兜底垃圾收集器;但是CMS
又在JDK14
被移除,ParNew
的位置就比较尴尬
-XX:+UseParNewGC
:手动指定新生代使用ParNew
并行垃圾收集器,不影响老年代
-XX:ParallelGCThreads=N
:设置年轻代并行垃圾收集线程的数量,当CPU
数量小于等于8
个,默认开启和CPU数量相同的垃圾收集线程;当CPU
数量大于8
个,ParallelGCThreads
的默认值为(5*CPU核数)/8
向下取整后加3;
Parallel
JDK8
的默认垃圾收集器,侧重于吞吐量,服务器端注重高并发和整体的吞吐量,服务器端适合使用Parallel
进行垃圾收集
-XX:+UseParallelGC
:手动指定年轻代使用Parallel
并行垃圾收集器,开启该JVM
参数会自动激活-XX:+UseParallelOldGC
-XX:+UseParallelOldGC
:手动指定老年代使用parallelOld
并行垃圾收集器,开启该JVM
参数会自动激活-XX:+UseParallelGC
-XX:ParallelGCThreads=N
:设置年轻代并行垃圾收集线程的数量,当CPU
数量小于等于8
个,默认开启和CPU数量相同的垃圾收集线程;当CPU
数量大于8
个,ParallelGCThreads
的默认值为(5*CPU核数)/8
向下取整后加3
-XX:MaxGCPauseMillis
:设置垃圾收集器最大暂停时间STW
,单位是毫秒,
为了尽可能将暂停时间控制在该参数指定的时间内,收集器工作时会自动调整Java
堆的大小和其他一些JVM
参数,能自动调整堆大小是因为开启了-XX:+UseAdaptiveSizePolicy
自适应堆大小调节策略
对于客户端用户追求低延迟能带来更好的响应;因为parallel
主打吞吐量,不建议对parallel
的暂停时间要求太苛刻
-XX:+UseAdaptiveSizePolicy
:开启JVM
的自适应堆大小调节策略
启用该配置后,新生代的大小、伊甸园区和幸存者区的比例、晋升老年代的对象年龄等参数都会由JVM
自动调整,以达到在堆大小、吞吐量和停顿时间之间的平衡点
一般开启自适应调节策略的情况下仅指定JVM
的堆最大大小、垃圾收集器的目标吞吐量和最大停顿时间,其他参数让JVM
自动调整完成调优工作
老师在最后一节课又说实际开发中不建议开启-XX:+UseAdaptiveSizePolicy
,但是没讲为什么
-XX:GCTimeRatio
:设置垃圾收集时间占总时间的比重[1/(N+1)
],即设置垃圾收集器的吞吐量
N
的取值范围为(0,100)
,默认值为99
,即垃圾收集时间不超过1%
,当-XX:maxGCPauseMillis
参数设置的越长,垃圾收集器收集时间占总时间的比例就越容易超出预设的比例
垃圾收集时间越短,系统的吞吐量就越大
CMS
CMS
在JDK1.5
引入,是HotSpot
虚拟机中第一款真正意义上实现了并发的垃圾收集器,并发即垃圾收集线程和用户线程可以同时运行,CMS
只能和Serial
或者ParNew
搭配[因为底层架构的问题无法和parallel
搭配],Serial
又只适用于硬件性能比较差的场景,很难适配现代服务端的高性能硬件场景,几乎只能和ParNew
搭配,基于标记清除算法,在JDK9
中被标记为废弃[可以通过-XX:+UseConcMarkSweepGC
来启用,但是用户会收到一个CMS
将在未来被移除的警告],在JDK14
被移除[此时如果还通过-XX:+UseConcMarkSweepGC
来启用CMS
,JVM
会给出警告自动以默认的垃圾收集器启动JVM
不会导致JVM
无法启动],但是目前在服务器与用户强交互的场景下还是非常常见
-XX:+UseConcMarkSweepGC
:手动指定使用CMS
收集器执行内存回收任务,开启该参数会自动启动-XX:+UseParNewGC
,自动使用ParNew
+CMS
+Serial Old
组合
-XX:CMSInitiatingOccupanyFraction
:设置垃圾收集器开始垃圾回收的堆内存使用率阈值
JDK5
及以前版本的默认值为68
,即老年代的空间使用率达到68%
时执行一次CMS
垃圾回收,JDK6
及以后的版本默认值为92%
,内存增长缓慢可以将阈值设置得稍大来降低CMS
的触发频率减少老年代垃圾回收次数明显改善应用程序性能;应用程序内存使用率增长很快应该降低该阈值避免CMS
垃圾收集速度跟不上用户的内存消耗速度,避免频繁触发老年代串行垃圾收集器的Full GC
次数
-XX:+UseCMSCompactAtFullCollection
:CMS
基于标记清除算法会产生内存碎片问题,配置该参数指定在执行完一次Full GC
后对内存空间进行压缩整理避免因为内存碎片导致频繁的Full GC
,因为单个老年代内存压缩整理过程无法并发执行,因此Full GC
的停顿时间会更长
-XX:CMSFullGCBeforeCompaction
:设置在执行多少次Full GC
后对内存空间进行压缩整理
-XX:parallelCMSThreads
:设置CMS
垃圾收集的线程数量,默认启动的CMS
垃圾线程数是(ParallelGCThreads+3)/4
[ParallelGCThreads
是年轻代并行垃圾收集器的垃圾收集线程数],如果设置了-XX:parallelCMSThreads
以该参数设置为主,受CMS
收集线程的影响,当CPU
资源紧张时,应用程序的性能在垃圾收集阶段可能非常糟糕
-XX:ConcGCThreads
:设置并发垃圾收集的线程数,默认值基于ParallelGCThreads
参数计算得到
-XX:+UseCMSInitiatingOccupancyOnly
:启用该参数可以使CMS
一直按CMSInitiatingOccupanyFraction
设置的老年代内存阈值进行垃圾收集二不会自动动态调整
-XX:+CMSScavengeBeforeRemark
:强制HotSpot
虚拟机在CMS remark
阶段前做一次Minor GC
用于提高remark
阶段的速度
-XX:+CMSClassUnloadingEnable
:启用该参数会允许CMS
垃圾收集器在执行垃圾收集时卸载方法区未被引用的类,该参数的启用可以在类加载器频繁加载和卸载类的场景中优化内存的使用
-XX:+CMSParallelInitialEnabled
:启用CMS
初始标记阶段多线程并行标记可触及对象,提高标记速度,JDK8
默认是开启的
-XX:+CMSParallelRemarkEnabled
:启用CMS
重新标记阶段多线程并行标记可触及对象,默认是开启的
-XX:+ExplicitGCInvokesConcurrent
、-XX:+ExplicitGCInvokesConcurrentAndUnloadsClasses
:这两个JVM
参数指定HotSpot
虚拟机在执行System.gc()
时使用CMS
垃圾收集器,而不是默认的Full GC
,可以显著减少垃圾收集带来的停顿时间
比如使用NIO
框架Netty
时,堆外内存的回收需要Full GC
完成,通过启用改参数可以使用CMS
垃圾收集器回收堆外内存,避免因为Full GC
带来的长时间停顿
该参数还会再并发垃圾收集的过程中卸载未被引用的类,在类加载器频繁加载和卸载类的场景中优化内存的使用
-XX:+CMSPrecleaningEnabled
:启用时CMS
垃圾收集器会在并发标记阶段前执行一次预清理提前清理掉一些容易识别的垃圾对象从而减少并发标记阶段的工作量,尤其在老年代中存在大量短生命周期对象的场景中可以提高垃圾收集的效率,减少后续并发标记阶段和清理阶段的停顿时间
G1
:
基于硬件水平提升CPU
核数越来越多、内存越来越大,互联网项目高并发场景越来越多与用户交互越来越频繁,推出了主打低延迟的G1
垃圾收集器;G1
收集器的Mixed GC
会同时回收新生代和部分老年代
建议使用G1
就不要使用-Xmn
和-XX:NewRatio
设置年轻的最大内存和新生代与老年代比例的大小,设置了会影响暂停时间的表现
-XX:+UseG1GC
:手动指定使用G1
垃圾收集器执行内存回收任务
-XX:G1HeapRegionSize
:设置每个Region
的大小,值为范围在1-32MB
之间的二次幂,目标是将Java
堆划分出约2048
个区域,默认为堆内存的1/2000
-XX:MaxGCPauseMillis
:设置期望的最大GC
暂停时间,JVM
会尽力实现GC
最大暂停时间,但是不保证一定实现,默认是200
ms
-XX:ParallelGCThread
:设置STW
时执行并行垃圾收集的线程数
-XX:ConcGCThreads
:设置执行并发标记的线程数,一般将线程数设置为并行垃圾收集线程数-XX:ParallelGCThread
的1/4
左右
-XX:InitiatingHeapOccupancyPercent
:设置触发G1
并发垃圾收集周期的Java
堆占用率阈值,默认值为45
-XX:G1NewSizePercent
、-XX:G1MaxNewSizePercent
:设置新生代占整个堆内存的最小百分比[默认5%]和最大百分比[默认60%]
-XX:G1ReservePercent=10
:该参数设置堆内存保留指定大小的一部分空间作为假天花板,预留出的空间用于减少新生代对象晋升到老年代时因为空间不足导致的Full GC
,默认情况下老年代预留10%
的空间给新生代对象晋升
G1
的Mixed GC
调优常用参数:一般会根据dump
文件和GC
日志文件做相应的调整
-XX:InitiatingHeapOccupancyPercent
:设置触发G1
全局并发标记的堆占用率阈值,默认值为45%
,可选值为0-100
,值为0
表示间断进行全局并发标记
-XX:G1MixedGCLiveThresholdPercent
:设置老年代的region
允许被回收时region
中的对象占比阈值,默认占用率为85%
,只有老年代的region
中存活的对象占用达到这个百分比才会在Mixed GC
中被回收
-XX:G1HeapWastePercent
:在每次YGC
和Mixed GC
前,会检查可回收垃圾占整个堆内存的比例是否低于该阈值,低于该阈值本轮混合回收即使没有达到8
次也会停止
老年代region
默认情况下会被分8次被回收,优先回收回收价值最高的region
,回收次数可以通过JVM
参数-XX:G1MixedGCCountTarget
设置,而且只有region
才会被回收,混合回收的回收集中包含1/8
的未被回收的老年代region
,全部的新生代region
,采用和年轻代回收一样的流程进行垃圾回收,只是多了回收已经被标记存活对象的老年代region
,混合回收不一定必须要进行8
次,如果JVM
发现可以回收的垃圾占堆内存的比例低于阈值10%
,就会停止混合回收,该阈值可以通过JVM
参数-XX:G1HeapWastePercent
设置,默认值是10
,意思是允许整个堆内存有10%
的空间被浪费,避免花费很多的时间进行GC
但是回收的内存却很有限
-XX:G1MixedGCCountTarget
:设置一次全局并发标记后,Mixed GC
执行的最大次数,默认为8
-XX:G1OldCSetRegionThresholdPercent
:设置一个Mixed GC
垃圾收集周期中要收集的老年代region
数的上限,默认值是java
堆的10%
垃圾收集器的选择策略
优先调整堆的大小让JVM
自适应调整
内存小于100M
使用串行垃圾收集器
单核单机程序没有停顿时间要求使用串行垃圾收集器
多CPU、需要高吞吐量、允许停顿时间超过1s
,选择并行垃圾收集器或者让JVM
自动选择
多CPU、追求低暂停时间、互联网应用响应延迟不超过1s
,使用并发垃圾收集器,官方推荐G1
,现在的互联网项目基本都使用G1
GC
日志相关参数
常用参数:
-verbose:gc
:标准参数类型,输出简化的GC
日志信息,等价于-XX:+PrintGC
,打印的内容都是相同的
-XX:+PrintGC
:等同于-verbose:gc
,输出简化的GC
日志信息
-XX:+PrintGCDetails
:发生GC
时打印垃圾收集的详细日志,进程退出时输出当前个内存区域的详细信息,这个GC
日志比-XX:+PrintGC
更详细,当同时配置参数-XX:+PrintGCDetails
和-XX:+PrintGC
时-XX:+PrintGC
参数会失效
-XX:+PrintGCTimeStamps
:输出GC
发生时的时间戳,该参数不能独立使用,JVM
正常运行但是不会打印任何结果,一般要搭配-XX:+PrintGCDetails
一起使用,在日志头部打印JVM
启动到GC
发生时刻的时间长度,单位是s
-XX:+PrintGCDateStamps
:输出GC
发生时的日期形式时间戳[如2013-05-04T21:53:59.234+0800
],该参数不能独立使用,JVM
正常运行但是不会打印任何结果,一般要搭配-XX:+PrintGCDetails
一起使用
-XX:+PrintHeapAtGC
:每次GC
前后都打印堆内存信息,该参数可以独立使用,也可以和-XX:+PrintGCDetails
一起使用,此时会将堆内存信息和GC
信息混合打印,并在JVM
进程结束以前打印一次堆内存信息
-Xloggc:<file>
:将GC
日志写入到一个文件中而不是打印到默认的控制台[标准输出],比如-Xloggc:d:/heaplog.log
其他参数
-XX:+TraceClassLoading
:监控类的加载信息
-XX:+PrintGCApplicationStoppedTime
:打印每次GC
线程的暂停时间
-XX:+PrintGCApplicationConcurrentTime
:在垃圾收集前打印出应用连续运行的时间
-XX:+PrintReferenceGC
:启用时会打印GC
过程中与引用处理相关的垃圾回收信息,会显示回收的软引用、弱引用和虚引用的数量
-XX:+PrintTenuringDistribution
:JVM
在每次minorGC
后都会打印出当前使用的幸存者区中对象的年龄分布
-XX:+UseGCLogFileRotation
:启用GC
日志文件的滚动功能,即GC
日志文件达到指定大小时JVM
会将日志内容输出到下一个日志文件,避免单个日志文件过大
-XX:NumberOfGClogFiles=1
:该参数通常与-XX:+UseGCLogFileRotation
一同使用,用于设置滚动日志文件的数量,默认值为0
,表示不滚动
-XX:GCLogFileSize=1M
:设置每个GC
日志文件的最大大小,日志文件达到指定大小后,日志内容会自动滚动到下一个日志文件
其他参数
-XX:+DisableExplictGC
:禁止HotSpot
执行显示调用System.gc()
导致的GC
操作,默认情况下是开启的,让JVM
的垃圾回收机制完全由自身的策略进行控制
-XX:ReservedCodeCacheSize=<n>[g|m|k]、-XX:InitialCodeCacheSize=<n>[g|m|k]
:分别表示设置即时编译器生成的本地机器代码缓存区域的预留空间大小以及初始空间大小,在较新的JVM
中代码缓存区域空间大小的默认值通常为240MB
,初始大小为160KB
-XX:+UseCodeCacheFlushing
:当代码缓存区域满了以后,JVM
会关闭即时编译器切换到纯解释执行模式,启用该参数后,代码缓存区域满了以后会自动清除代码缓存中的部分缓存为新的即时编译任务腾出空间
在某些情况下代码缓存被填满可能会导致JVM
抛出java.lang.OutOfMemoryError:CodeCache is full
异常,启用该参数可以通过释放一些空间来缓解这种问题
-XX:+DoEscapeAnalysis
:开启逃逸分析
-XX:+UseBiasedLocking
:开启偏向锁
-XX:+UseLargePages
:开启使用大页面
-XX:+UseTLAB
:使用TLAB
,默认开启
-XX:+PrintTLAB
:打印TLAB
的打印情况
-XX:TLABSize
:设置TLAB
的大小
通过Java
代码获取JVM
参数
方式一:通过Runtime
获取
long initialMemory =Runtime.getRuntime().totalMemory()
、
long maxMemory=Runtime.getRuntime().maxMemory()
Runtime
类还可以获取一些内存、CPU核数相关的数据
方式二:java.lang.management
包用于本地或者远程监视和管理java
虚拟机和其他组件,其中的ManagementFactory
比较常用
xxxxxxxxxx
/**
* @描述
* 打印结果
* INIT HEAP: 508m
* MAX HEAP: 7225m
* USE HEAP: 22m
*
* Full Information:
* Heap Memory Usage: init = 532676608(520192K) used = 23970824(23409K) committed = 510656512(498688K) max = 7575961600(7398400K)
* Non-Heap Memory Usage: init = 2555904(2496K) used = 9692424(9465K) committed = 10027008(9792K) max = -1(-1K)
* 当前堆内存大小: 487m
* 空闲堆内存大小: 464m
* 最大可用堆内存大小: 7225m
* @author Earl
* @version 1.0.0
* @创建日期 2025/04/22
* @since 1.0.0
*/
public void testManagementFactory(){
MemoryMXBean memoryMXBean = ManagementFactory.getMemoryMXBean();
MemoryUsage usage = memoryMXBean.getHeapMemoryUsage();
System.out.println("INIT HEAP: "+usage.getInit()/1024/1024+"m");
System.out.println("MAX HEAP: "+usage.getMax()/1024/1024+"m");
System.out.println("USE HEAP: "+usage.getUsed()/1024/1024+"m");
System.out.println("\nFull Information:");
System.out.println("Heap Memory Usage: "+ memoryMXBean.getHeapMemoryUsage());
System.out.println("Non-Heap Memory Usage: "+ memoryMXBean.getNonHeapMemoryUsage());
//通过Java代码获取系统信息
System.out.println("当前堆内存大小: "+(int) Runtime.getRuntime().totalMemory()/1024/1024+"m");
System.out.println("空闲堆内存大小: "+(int) Runtime.getRuntime().freeMemory()/1024/1024+"m");
System.out.println("最大可用堆内存大小: "+Runtime.getRuntime().maxMemory()/1024/1024+"m");
}
GC
日志分析
GC
相关参数
-verbose:gc
:标准参数类型,输出简化的GC
日志信息,等价于-XX:+PrintGC
,打印的内容都是相同的
-XX:+PrintGC
:等同于-verbose:gc
,输出简化的GC
日志信息
-XX:+PrintGCDetails
:发生GC
时打印垃圾收集的详细日志,进程退出时输出当前个内存区域的详细信息,这个GC
日志比-XX:+PrintGC
更详细,当同时配置参数-XX:+PrintGCDetails
和-XX:+PrintGC
时-XX:+PrintGC
参数会失效
该参数能打印当前新生代和老年代使用的是什么垃圾收集器
-XX:+PrintGCTimeStamps
:输出GC
发生时的时间戳,该参数不能独立使用,JVM
正常运行但是不会打印任何结果,一般要搭配-XX:+PrintGCDetails
一起使用,在日志头部打印JVM
启动到GC
发生时刻的时间长度,单位是s
-XX:+PrintGCDateStamps
:输出GC
发生时的日期形式时间戳[如2013-05-04T21:53:59.234+0800
],该参数不能独立使用,JVM
正常运行但是不会打印任何结果,一般要搭配-XX:+PrintGCDetails
一起使用
-XX:+PrintHeapAtGC
:每次GC
前后都打印堆内存信息,该参数可以独立使用,也可以和-XX:+PrintGCDetails
一起使用,此时会将堆内存信息和GC
信息混合打印,并在JVM
进程结束以前打印一次堆内存信息
-Xloggc:<file>
:将GC
日志写入到一个文件中而不是打印到默认的控制台[标准输出],比如-Xloggc:d:/heaplog.log
-XX:+TraceClassLoading
:监控类的加载信息
-XX:+PrintGCApplicationStoppedTime
:打印每次GC
线程的暂停时间
-XX:+PrintGCApplicationConcurrentTime
:在垃圾收集前打印出应用连续运行的时间
-XX:+PrintReferenceGC
:启用时会打印GC
过程中与引用处理相关的垃圾回收信息,会显示回收的软引用、弱引用和虚引用的数量
-XX:+PrintTenuringDistribution
:JVM
在每次minorGC
后都会打印出当前使用的幸存者区中对象的年龄分布
-XX:+UseGCLogFileRotation
:启用GC
日志文件的滚动功能,即GC
日志文件达到指定大小时JVM
会将日志内容输出到下一个日志文件,避免单个日志文件过大
-XX:NumberOfGClogFiles=1
:该参数通常与-XX:+UseGCLogFileRotation
一同使用,用于设置滚动日志文件的数量,默认值为0
,表示不滚动
-XX:GCLogFileSize=1M
:设置每个GC
日志文件的最大大小,日志文件达到指定大小后,日志内容会自动滚动到下一个日志文件
GC
分类[垃圾回收分类]
GC
按照回收区域的分类分为部分收集Partial GC
和整堆收集Full GC
部分收集
新生代垃圾收集[Minor GC|YGC]:频繁速度快
老年代垃圾收集[Major GC|Old GC]:目前只有CMS
会有单独收集老年代的行为,通常对象在YGC
以后仍然不能存放在伊甸园区才会放在老年代,老年代放不下才会执行Major GC
,因此Major
前都会有一次Minor GC
混合垃圾收集[Mixed GC]:收集整个新生代以及部分老年代
目前只有G1
有混合垃圾收集行为
整堆收集[Full GC]:收集整个Java
堆和方法区,JVM
规范在逻辑上将方法区也算在堆里面,落地的时候设置JVM
参数时堆只考虑新生代和老年代
触发Full GC
的几种情况:
调用System.gc()
时会建议系统执行FullGC
,系统会根据运行情况自行判断是否执行Full GC
老年代或者方法区空间不足
从YGC
晋升老年代的对象平均大小大于老年代的可用内存
大对象直接进入老年代但是老年代的可用空间不足
导致G1
进行Full GC
的原因:
YGC
前老年代的可用连续空间小于前几次年轻代晋升老年代的平均大小会直接将YGC
替换成Full GC
,方法区空间满了时[概率小,因为方法区使用的是本地内存,空间很大]
调用System.gc()
时会建议系统执行FullGC
,系统会根据运行情况自行判断是否执行Full GC
混合回收完成前老年代的空闲空间已经被耗尽,此时就会使用Full GC
暂停所有用户线程来进行兜底垃圾收集[比如暂停时间设置的太短,回收频率变高,但是如果垃圾回收的速度跟不上垃圾产生的速度内存最终还是会被耗尽并触发Full GC
]
GC
日志的解析
注意G1
的日志和其他垃圾收集器的日志变化比较大,后面再专门总结
-XX:+PrintGCDetails
:打印详细的GC
处理日志和堆空间内存情况,不同的GC
打印信息不同,参数优先级比下面一个参数更高,显示的内容依次为
如果配置了JVM
参数-XX:+PrintGCDateStamps
日志的打头为2013-05-04T21:53:59.234+0800
即GC
发生的时刻;如果配置的是JVM
参数-XX:+PrintGCDateStamps
日志的打头为JVM
启动到GC
发生时刻的以秒为单位的时间
GC
类型[Minor GC
会显示为GC
,Full GC
会显示为FullGC
]、GC
原因[Minor GC
一般都是Allocation Failure
表示没有足够的内存创建新的对象;Full GC
的原因可能有Metadata GC Threshold
元空间内存不够用,Ergonomics
新生代对象或者大对象晋升老年代内存空间不足,System
手动调用System.gc()
方法]
元空间内存不足导致的Full GC
可能最终的结果是老年代内存因为新生代对象晋升反而变大了,因此Full GC
后看到老年代占用内存反而增大了不要慌
新生代使用的垃圾收集器[一般不同的垃圾收集器显示不同的新生代名字,我们可以根据名字的区别判断对应区域垃圾收集使用的哪种垃圾收集器]和新生代占用内存垃圾收集前后变化以及新生代的总容量[如[PSYoungGen:76800K->8433K(89600K)]
箭头前面是GC
前的新生代内存占用、箭头后面是GC
后的新生代内存占用、括号内是新生代总容量,注意该总容量是伊甸园区加一个幸存者区,是整个新生代的9/10
];以上为Minor GC
的日志内容,Full GC
还会额外打印老年代使用GC
和老年代占用内存GC
前后变化以及老年代的总容量[格式与新生代GC
日志格式是一样的]
Serial
显示新生代名字为[DefNew
;ParNew
显示新生代名字为[ParNew
;Parallel
显示新生代名字为[PSYoungGen
;Parallel Old
显示老年代的名字为[ParOldGen
;G1
显示garbage-first heap
整个堆占用内存GC
前后变化和堆内存总容量[GC
前堆内存已使用容量->GC
后堆内存已使用容量(堆内存总容量),其中堆内存总容量为十分之九的新生代+老年代]
以上为Minor GC
的日志内容,Full GC
日志还会额外打印方法区占用内存GC
前后变化
GC
过程以秒为单位的持续时间;Times
中user
是垃圾收集器花费的所有CPU
时间[CPU
工作在用户态花费的时间,不包含其他进程的执行时间即垃圾回收线程阻塞的时间]、sys
是花费在等待系统调用或者系统事件的时间[CPU
工作在内核态花费的时间]、real
是从GC
开始到GC
结束的整个持续时间[GC
开始到结束花费的现实时间,包含和其他线程抢夺CPU时间片以及等待的时间,一般real
会小于user
+sys
,这是因为现在一般使用的都是多核CPU,如果real
>user
+sys
说明IO负载比较重或者CPU核数不够用]
堆信息依次显示新生代def new generation
;老年代tenured generation
;永久代compacting perm gen
;元空间MetaSpace
的最大可用内存和已使用的内存,还会显示每个区域的虚拟内存开始和结束的地址
GC
日志分析工具
开发者需要使用上述GC
日志数据计算GC
的吞吐量、暂停时间等数据,GC
日志一多自己计算这些数据是不可能的,一般使用GC
日志分析工具来对GC
数据进行统计和辅助分析,GCEasy
相对来说是比较好用的GC
日志分析工具
在线GC
日志分析网站,官网:https://gceasy.io/
,可以通过GC
日志分析进行内存泄漏检测、GC
暂停原因分析、JVM
配置建议优化,有些功能是收费的
GCEasy
的分析数据
JVM Memory Size
JVM
内存各区域大小
发生OOM
时各区域内存大小
Key Performance Indicators
Thoughput
吞吐量
Latency
延迟情况/暂停时间[平均暂停时间和最大暂停时间,一定范围内的暂停时间占总样本的百分比]
Interactive Graphs
GC
前后的堆内存占用情况[红色三角就是GC
时间点,横轴是时间]
GC
的持续时间
新生代、老年代、元空间每次GC
前后的总内存大小、GC
前后占用内存大小
GC Statistics
垃圾收集统计数据
GCViewer
介绍:Oracle
免费开源离线版的GC
日志分析工具,用于可视化查看SUN/Oracle
、IBM
、HP
、BEA
的JVM
产生的GC
日志,用于可视化JVM
参数-verbose:gc
和-Xloggc:<file>
生成的gc日志,统计垃圾回收的吞吐量、累计暂停时间、最长暂停时间
下载
源码:https://github.com/chewiebug/GCViewer
运行版本:https://github.com/chewiebug/GCViewer/wiki/Changelog
运行
下载的是jar
包,双击gcviewer-1.3x.jar
或者使用命令java -jar gcviewwe-1.3x.jar
启动,需要安装适配对应版本的JDK
GCViewer
的数据解析很有限,除了Event details
中展示部分统计数据,主要的信息展示在Summary
、Memory
、Pause
面板中
GChisto
介绍:gc日志分析工具,分析GC
日志数据通过图表、报表、列表等不同形式展示GC
次数、频率、持续时间
下载需要从SVN
拉取并进行编译,而且不怎么维护存在很多bug
,界面也很粗糙
HPjmeter
介绍:只能打开由JVM
参数-verbose:gc
和-Xloggc:gc.log
生成的日志文件,只要添加了其他参数生成的GC
日志文件就无法被HPjmeter
打开,HpJmeter
集成了HPjtune
功能,功能比较强大,一般用来分析在HP
机器上产生的GC
日志文件
OOM
常见应用场景和解决方案[课程在尚硅谷官网大厂学院里面作为JVM
的第七章,具体看宋红康JVM
最后一节,统一在JVM
与GC
调优章节,300块钱]
堆溢出
元空间溢出
GC overhead limit exceeded
线程溢出
性能优化思路[课程在尚硅谷官网大厂学院里面作为JVM
的第八章,具体看宋红康JVM
最后一节,统一在JVM
与GC
调优章节,300块钱]
JMeter
压测
调整堆大小、垃圾收集器、提高服务吞吐量
JIT
优化
调整G1
并发垃圾收集线程数
调整新生代和老年代比例
CPU
占用很高问题如何排查
日均百万级别的订单交易系统如何设置JVM
参数
JVM
有哪些性能调优方法、调优参数、调优命令和调优工具
常用调优工具
jvisualvm
jconsole
jprofiler
GCViewer
GC Easy
Java Flight Recorder
:JMC
中的Java
飞行记录仪,可以实时对JVM
内存空间进行监控
Eclipse:Memory Analyzer Tool
:MAT
可以对jMap
导出的文件进行离线分析
JDK
命令行指令如jinfo
、jstat
、javap
、jMap
本来只想简单点只总结面试要点,没忍住又肝了一遍,导致笔记分散在两个文档中,另一个内容较少的文档拷贝到该文档的附录中,后面复习的时候再将附录中的另一个文档内容合并整理到正文中
软件jclasslib
和Binary Viewer
能查看字节码中的字节数据,将字节码文件拖入Binary Viewer
能看到对应字节码,字节码文件的模数是16进制的CAFEBABE
,一共占用四个字节;jclasslib
能看到字节码文件字节编译为字符后的字节码文件结构
IDEA的class
文件本身就是被反编译以后的文件,javap
指令是解析字节码的指令[要解析出私有变量需要加参数-p
],javap -v -p Order.class > test.txt
是将反解析后的结果写入到test.txt
文件中
成员变量中被static
修饰的称为静态变量也叫类变量,没有被static
修饰的称为实例变量
辨析类变量、局部变量和实例变量:在链接的准备阶段在方法区为类变量开辟空间并赋默认值,在初始化阶段为类变量显示赋值;局部变量在使用前必须先进行显示赋值,否则编译不通过,局部变量不会自动赋值默认值;实例变量随着对象的创建在堆区分配空间并赋值默认值;
据说腾讯的虚拟机是Kona JDK
IO数据传输使用的基础工具是字节数组或者字符数组,IO是基于流Stream
,NIO
基于Buffer
Java
是半编译型半解释型语言
语言的发展历史
机器码:二进制编码表示的指令。机器指令可以被计算机理解接受,但是不易于人们理解记忆[人们把业务翻译成数据的计算过程编写成CPU按一定顺序数据处理的指令],人不方便理解和记忆,编程容易出错;机器语言输入计算机,CPU直接读取运行,执行速度非常快,机器指令和CPU紧密相关,不同种类的CPU对应的机器指令差别非常大
指令:用英文简写的指令比如mov
、inc
等替代二进制机器指令,增强机器码的可读性,人们不用再记忆某个操作的二进制机器码;不同硬件平台执行同一个操作的机器码可能不同,因此同一个指令mov
在不同硬件平台上的机器码也可能不同
指令集:每个平台支持的所有指令成为对应平台的指令集,比如X86
架构平台的x86
指令集,ARM
架构的ARM
指令集
汇编语言:汇编语言用助记符代替机器指令的操作码,用地址符号或者标号代替指令或者操作数的地址,不同的硬件平台,汇编语言对应不同的机器语言指令集,汇编语言通过汇编过程转成机器指令,汇编语言编写的程序需要翻译成机器指令码才能被计算机识别和执行
高级语言:高级语言更接近人的语言,高级语言也需要解释编译成机器指令才能被计算机识别执行,完成该解释编译过程的程序叫做解释程序或者编译程序,解释执行就对应指令流被解释器逐条解释执行,编译执行程序将指令生成对应的机器指令
高级语言通过编译过程先生成汇编语言,汇编语言通过汇编过程生成机器指令再交给CPU执行,这实际上是C
和C++
的源码处理环节;编译过程可以分为编译和汇编两个阶段,编译过程是读取字符流源程序,对源程序进行词法和语法分析,将高级语言指令转换成功能等效的汇编代码;汇编构成是将汇编语言翻译成机器指令
字节码是一种中间码状态的二进制代码文件,在Java
源程序和汇编语言之间为了跨平台特性引入了字节码指令,通过前端编译器实现跨语言,通过不同平台的虚拟机对同一份字节码文件转译为对应平台上的可直接执行的指令来实现跨平台
字节码的典型应用为Java bytecode
方法中也可以像类一样定义非静态代码块
Java
不同版本新特性的学习角度
语法层面:Lambda
表达式、switch
表达式、自动装箱拆箱、Enum、泛型...
API
层面:新API
的引入[Stream API、新的日期API、Optional类、集合],旧API
的移除,API的更新[比如String
的value
属性]...
底层优化:JVM
优化、GC
变化、对新语言的支持、元空间和串池的变化...
32
位操作系统可以选择使用Client
或者Server
模式,通过java -version
可以查看JVM
的工作模式
十种排序方式
IOS
底层硬件、操作系统、开发语言都是Apple
自己家的,兼容性非常好、也无需考虑扩展性,软件设计不需要过多的接口,系统流畅性就非常好;安卓的CPU
等硬件生态涉及到各种各样的厂商、接口设计特别冗余、主要是这个原因导致的卡顿
JSR
[Java Specification Request
]:Java规范提案;JEP
[Java Enhancement Proposal
]:Java
增强提议;JCP
[Java Community Process
]:Java社区进程,管理和维护Java
技术规范的开放性国际组织,制定Java平台的各种技术规范,管理规范提案JSR
《新一代垃圾回收器ZGC设计与实现》
JVM
规范网址:https://docs.oracle.com/javase/specs/index.html
弹幕推荐了一个IDEA
插件Binary/hexadecimal
软件Beyond Compare
可以比较两个文件的区别,自动发现两个文件不同的部分
string.concat(str2)
是将字符串str2
拼接在字符串string
的末尾
zoomit
画笔软件
通过不同实例对象获取引用类型静态变量始终获取的是同一个对象,程序运行中通过静态变量关联的对象不会被回收,比如手动对静态变量中的引用置空对应的对象才会被回收,这在开发中要非常注意,这会导致内存泄漏
静态变量不会随着实例的销毁而销毁,会与class
对象的生命周期保持一致,一般会与JVM
的生命周期保持一致
实例变量会随着实例的销毁而销毁
IDEA
中的DEBUG
功能据说也使用了javaAgent
技术
面试要点内存结构、GC算法、垃圾回收器、GC
的JVM
参数、字节码文件结构、字节码指令、类加载过程、类加载器种类、命令行调优工具、GUI
调优工具、常见JVM
参数、GC
日志分析
BATJTMDPKQ
分别对应的公司为百度、阿里、腾讯、京东、头条、美团、滴滴、拼多多、快手、趣头条
JVM
规范在oracle
官网可以下载,文件名字为Java Language and Virtual Machine Specifications,前者为语言的规范,后者为虚拟机的规范,都是英文的,中文可以参考《Java虚拟机规范》[这本书就像是官方规范的翻译,一般用来查阅]、对JVM的理解和使用首推《深入理解Java虚拟机-周志明》、《自己动手写Java虚拟机》[这个Java虚拟机是用GO语言实现的,GO语言本身就有比较完善的垃圾回收机制,用C语言因为内存完全暴露给程序员会比较崩溃]
互联网基于JS、人工智能基于Python、微服务基于Go
信息产业有三大技术难题:CPU、操作系统、编译器
自己编写Java
虚拟机要实现完整的规范甚至能达到商用的性能和稳定性非常难,如果只是学习底层的原理跑个程序就不难
公司开发习惯用Mac
,Mac
最突出的地方是硬件[CPU结构和架构设计]、操作系统、常用软件都是由Apple
公司自己设计完成的,耦合度很高,因此整体性能非常好,一般大公司都会配备Mac
,Mac
上面最有价值的就是那个操作系统,没必要在Mac
上再装一个Windows
弹幕提示IDEA可以使用jclasslib
插件查看源码对应的字节码指令,在字节码文件对应目录下使用Java
命令javap -v 字节码文件名.class
,执行结果中的Code
中的第一个stack=...
下的内容就是对应方法名的方法体的字节码指令
IDEA中Java
源码可以通过Build--Recompile 文件名重新编译Java
源码
javap -v 字节码文件名.class
是对字节码文件进行反汇编,方便查看字节码文件中的信息
[javap
命令执行结果]
xxxxxxxxxx
Earl@Earl MINGW64 /d/maven-space/mall/renren-generator/target/classes/io/renren/adaptor (master)
$ javap -v HelloWorld.class
Classfile /D:/maven-space/
mall/renren-generator/target/classes/io/renren/adaptor/HelloWorld.class
Last modified 2025-2-17; size 463 bytes
MD5 checksum 50a61ade8c63b9462a666d4c2bb91357
Compiled from "HelloWorld.java"
public class io.renren.adaptor.HelloWorld
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
#Constant pool就是该字节码文件需要用到的常量池中的数据
Constant pool:
#1 = Methodref #3.#20 // java/lang/Object."<init>":()V
#2 = Class #21 // io/renren/adaptor/HelloWorld
#3 = Class #22 // java/lang/Object
#4 = Utf8 <init>
#5 = Utf8 ()V
#6 = Utf8 Code
#7 = Utf8 LineNumberTable
#8 = Utf8 LocalVariableTable
#9 = Utf8 this
#10 = Utf8 Lio/renren/adaptor/HelloWorld;
#11 = Utf8 main
#12 = Utf8 ([Ljava/lang/String;)V
#13 = Utf8 args
#14 = Utf8 [Ljava/lang/String;
#15 = Utf8 i
#16 = Utf8 I
#17 = Utf8 MethodParameters
#18 = Utf8 SourceFile
#19 = Utf8 HelloWorld.java
#20 = NameAndType #4:#5 // "<init>":()V
#21 = Utf8 io/renren/adaptor/HelloWorld
#22 = Utf8 java/lang/Object
{
public io.renren.adaptor.HelloWorld();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 3: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lio/renren/adaptor/HelloWorld;
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=2, args_size=1
#这个带序号的就是对应方法的字节码
0: iconst_5
1: istore_1
2: return
LineNumberTable:
line 5: 0
line 6: 2
LocalVariableTable:
Start Length Slot Name Signature
0 3 0 args [Ljava/lang/String;
2 1 1 i I
MethodParameters:
Name Flags
args
}
SourceFile: "HelloWorld.java"
[int i = 2 + 3;
字节码示例]
xxxxxxxxxx
Code:
stack=1, locals=2, args_size=1
#这个带序号的就是对应方法的字节码
0: iconst_5
1: istore_1
2: return
[int i=2;int j=3;int k=i+j;
字节码示例]
xxxxxxxxxx
Code:
stack=2, locals=4, args_size=1
0: iconst_2 #定义一个常量2
1: istore_1 #将常量保存在索引为1的操作数栈中
2: iconst_3 #定义一个常量3
3: istore_2 #将常量保存在索引为2的操作数栈中
4: iload_1 #加载操作数栈索引为1的数据
5: iload_2
6: iadd #将加载的两个数据相加
7: istore_3 #求和后的结果保存在索引为3的操作数栈中
8: return
IOS
系统的流畅度是因为从硬件架构到系统到应用的编程语言都是Apple
自己的,整体高度耦合,共同作用实现的高流畅度;因此单纯用其他牌子的手机安装IOS
系统是不能提高手机的流畅度的
JVM
概述
概念:
虚拟机:
虚拟机就是一台模拟计算机的软件[计算机硬件上面一层是操作系统、操作系统上面一层是软件],用来执行一系列虚拟计算机指令,虚拟机可以分为系统虚拟机和程序虚拟机
系统虚拟机如Virtual Box
、VMware
是对物理计算机的仿真,提供可运行完整操作系统的软件平台
程序虚拟机如JVM
,专门设计来执行单个计算机程序,在Java虚拟机中执行的指令称为Java
字节码指令,Java
虚拟机是解释运行Java
字节码的虚拟计算机,任何语言编译后形成的字节码文件只要遵循JVM
规范中的要求就可以被JVM
解释运行,JVM平台的各种语言都可以共享JVM带来的跨平台性、垃圾回收器和JIT即时编译器
JVM负责装载二进制字节码,并将二进制字节码解释编译为对应平台上的机器指令并交给底层硬件执行,对于每条Java
指令JVM
都有详细定义,Java
程序与计算机的关系是用户的Java
代码被编译为字节码文件、字节码文件被JVM
装载解释成计算机指令交给操作系统,操作系统将机器指令交给计算机硬件执行
特点:一次编译,处处运行[所有的虚拟机实现都基于一次编译处处运行的原则];自动内存管理;自动垃圾回收机制[程序员编写Java
代码不需要关注内存泄漏和内存溢出的风险,但是也会弱化Java
程序员对内存管理、性能优化方面的能力]
HotSpot
虚拟机是OpenJDK
和OracleJDK
共用的Java
虚拟机
特点:
Java
摒弃了C语言和C++需要动态分配内存和手动回收垃圾的特点,程序员自己分配内存和回收垃圾技术好会比较舒服,可以避免内存冗余,内存使用非常高效;技术不好内存管理就可能十分混乱
JVM规范有版本,会不停迭代,同时JVM规范是虚的,需要通过虚拟机来对规范进行实现,虚拟机不同厂商的落地实现也不同,Oracle不仅发布JVM规范,自己也实现了HotSpot
虚拟机,HotSpot
虚拟机的地位就像普通话一样
Java程序被编译成一份字节码文件,这个独一份的字节码文件可以在Win、Linux、Mac上的JVM分别解释运行,即字节码文件本身就具备跨平台的特性,字节码文件是Java虚拟机的执行基础,字节码文件可以通过不同语言编译获得,不一定只能通过Java语言编译获得,像Kotlin
、Scala
、JRuby
、JavaScript
等语言都可以通过提供各自的编译器并被编译成字节码文件在Java虚拟机上解释运行,只要这些字节码文件遵循Java
虚拟机规范,这些字节码文件就能被虚拟机执行,因此Java虚拟机不要求源程序必须使用Java
来写,因此JVM也是一种跨语言平台[Java7以后Java虚拟机通过JSR292规范实现在Java虚拟机上运行非Java语言编写的程序,并推出一系列项目和改进功能比如DaVinci Machine
项目、Nashorn
引擎、InvokeDynamic
指令、java.lang.invoke
包等等],只关心字节码文件,不是单纯地与Java语言终身绑定,只要其他编程语言的编译结果满足虚拟机的内部指令集、符号表和其他辅助信息[JVM出于安全考虑,对字节码文件有一些结构性约束和强类型语法规范]**,那么其就是一个能被虚拟机识别、装载和运行的有效字节码文件,时至今日Java虚拟机是比Java语言更成功、更优秀和更伟大的产品,因此字节码称为Java字节码不太规范,应该称为JVM字节码
因为JVM是跨语言平台,在Java平台上的多语言混合编程成为趋势,特别是在大型平台中混合使用不同语言解决特定领域的问题,比如使用Clojure
解决并行处理问题,展示层使用JRuby/Rails
,中间层使用Java
,同时还可以实现不同语言间的交互[比如在Java
中调用C
],只需要在编译的时候按照各自的编译规则转换为字节码即可,能够实现Java
调用C
就像调用Java
自己的API
一样方便,不同语言最终编译后的字节码都运行在同一台虚拟机上,最终目的是推动JVM
从Java
虚拟机向多语言虚拟机的方向发展
源码编译成字节码文件需要编译器、字节码文件被JVM
解释运行还需要解释器和JIT
即时编译器
JVM
结构图
[粗糙版]
1️⃣:字节码文件使用类加载器[也可以叫类装载器]加载到JVM
内存的方法区中生成一个最大的Class
对象并对静态属性做初始化,该过程涉及到加载、链接、初始化三个步骤,在运行时数据区中的方法区生成对应的Class
实例
2️⃣:运行时数据区包含方法区、Java
栈、本地方法栈、堆、程序计数器,其中方法区和堆是多线程共享的,Java
栈、本地方法栈和程序计数器都是每个线程独一份,运行时数据区对应的类是Runtime
类,这个类也是被设计成单例的
3️⃣:执行引擎,执行引擎分为解释器、JIT
即时编译器、垃圾回收器;
解释器解释运行字节码,对所有代码都解释运行效率不高,因此使用JIT
即时编译器将反复执行的热点代码专门编译成机器指令并缓存在方法区方便解释器解释运行的时候直接调用;解释器解释执行字节码涉及到去虚拟机栈的局部变量表中取数据和操作数栈,过程中需要创建对象需要使用堆空间,指令依次执行需要使用程序计数器,需要调用本地类库需要使用本地方法栈
垃圾回收器负责收回程序运行期间使用完毕的内存
操作系统只能识别机器指令、执行引擎充当的就是将高级语言翻译成机器指令的翻译者,高级语言被翻译成汇编语言,汇编语言被翻译成机器指令,机器指令交给CPU执行
主流的虚拟机都采用解释器解释执行字节码和JIT
即时编译并存的方式
[细节版]
1️⃣:类加载子系统[Class Loader SubSystem]:字节码文件被加载依次经过加载、链接和初始化三个环节
加载:使用类加载器将字节码文件加载到内存中,典型的几类类加载器为引导类加载器BootStrap ClassLoader
、扩展类加载器Extension ClassLoader
、应用/系统类加载器Application ClassLoader
;此外用户还可以自定义类加载器
链接:链接分为验证、准备、解析三个环节
初始化:主要涉及静态变量的显式初始化
2️⃣:运行时数据区[Runtime Data Areas]:运行时数据区包含的结构依次为
PC
寄存器/程序计数器区域[PC Registers]:每个线程一份程序计数器
虚拟机栈区域[Stack Area]:每个线程一份虚拟机栈,虚拟机栈中的基本单元是栈帧[栈帧的内部结构分为LV
局部/本地变量表、OS
操作数栈、DL
动态链接、RA
方法返回地址]
本地方法栈[Native Method Stack]:本地C
类库的方法调用执行的栈
堆区[Heap Area]:Java
中创建的对象主体都分配在堆区,也是JVM
中内存最大的一块空间,也是GC重点考虑的一块空间,同时堆区也是多线程共享的资源
方法区[Method Area]:方法区主要存放类信息、常量、域信息、方法信息;方法区是HotSpot
虚拟机独有;JDK7
以前方法区的落地实现叫永久代,JDK7
以后叫元空间[永久代和元空间都是方法区的落地实现]
3️⃣:执行引擎[Execution Engine]:执行引擎包含解释器、JIT
即时编译器、垃圾回收器,负责将字节码指令翻译成机器指令供CPU
执行
JVM
的生命周期
启动
JVM
的启动通过引导类加载器创建一个初始类来完成,类和类名由具体的JVM
实现自行确定;JVM
要加载的类很多,涉及到的类加载器也很多,这个最初的类不是用户自定义的类也不是核心API对应的类
用户自定义的类通过系统类加载器[应用类加载器]加载到内存中,核心API相关的类需要被引导类加载器加载,父类的加载要先于子类的加载,加载了自定义的类和对应父类后,根据main
方法相继加载需要使用到的类
执行
JVM
唯一的任务就是执行Java
程序,启动后就开始执行Java
程序,程序运行结束JVM
就自动结束,Java程序实际上是正在执行的Java虚拟机进程,使用jps
命令能查看当前计算机上执行的所有Java
进程,进程名为启动类的类名
退出
程序正常执行结束会导致虚拟机的退出
程序执行过程中遇到异常或者错误异常终止会导致虚拟机退出
操作系统错误会导致JVM
进程终止
通过Java程序某个线程调用Runtime
类或者System
类的exit
方法或者Runtime
的halt
方法,且Java安全管理器也允许此次exit
或者halt
操作,JVM
进程也会终止
JNI
[Java Native Interface
]本地方法接口规范中的JNI Invocation API
加载或者卸载JVM
时,JVM
也会先退出
Java
系统结构图
最外层的JDK
相较于JRE
增加了javac
将Java
源程序编译成Java
字节码文件,JConsole
即JVM
性能监控组件、javadoc
文档组件、JMC
内存泄漏检测组件等
JRE
中包含完整Java SE
的API
安卓Android
的系统结构图
Google
的安卓基于Linux
内核即Linux Kernel
,在该基础上提供Libraries
包括数据库在内的各种库,在库的基础上提供应用框架Application framework
,在框架上提供具体的应用程序applications
,Android Runtime
即安卓在5.0
以前都用的Dalvik
虚拟机[该虚拟机和JVM没什么关系,但是安卓使用Java
语言作为开发语言,Java
语言被编译为.dex
文件]
JVM
指令集架构模型
指令集架构有基于栈的指令集架构和基于寄存器的指令集架构两种,Java
采用的是基于栈的指令集架构,寄存器只有一个PC Register
程序计数器
基于栈的指令集架构
每执行一个方法就对方法做一次栈帧的入栈操作,方法执行完以后做一次栈帧的出栈操作
执行一个指令需要直到指令的地址和指令操作的数据即操作数,一地址指令指指令地址有一个,二地址指令指指令地址有两个,零地址指令指没有指令地址只有操作数;基于栈的指令集架构一般都是零地址指令,因为栈只会操作当前栈顶的数据,因此栈顶数据本身就是指令
基于栈的字节码指令以每八位单字节的方式对齐[单条指令一个字节],基于寄存器的指令以十六位双字节的方式对齐;但是由于指令要进行出栈入栈操作,虽然基于栈的单个指令字节数更小,但是总的指令数量更多,主要就是多在出栈入栈指令上
[基于栈的指令示例]
xxxxxxxxxx
iconst_2 //常量2入栈
istore_1
iconst_3 //常量3入栈
istore_2
iload_1
iload_2
iadd //常量2、3出栈,执行相加操作
istore_0 //结果5入栈
[基于寄存器的指令示例]
xxxxxxxxxx
mov eax,2 //将eax寄存器的值设为2
add eax,3 //将eax寄存器的值加3
特点[跨平台、指令集小、编译器容易实现,缺点是性能不高,同样的功能需要更多的指令]
设计和实现更简单,适用于像嵌入式这种资源受限的系统
使用零地址指令方式避开寄存器的指令分配问题
指令流中的指令大部分是零地址指令,执行过程依赖于操作栈,指令集更小,相应的编译器也更容易实现;但是相比于寄存器涉及到出栈入栈指令指令数量比较多
栈不需要硬件支持,可移植型和跨平台性更容易实现
基于寄存器的指令集架构
x86
的二进制指令集和传统PC
以及安卓的Davlik
虚拟机都是基于寄存器的指令集架构
特点
指令由CPU的高速缓冲区中执行;执行速度比栈快,性能优秀,执行更高效
但是也是因为指令集的指令完全依赖于CPU中的寄存器,因此与硬件的耦合度比较高,不同平台的CPU架构不同,因此可移植性差;安卓因为不需要跨平台,又想要性能就选择了基于寄存器的指令集架构
完成一项操作相较于栈的指令数量更少,单条指令占用两个字节
基于寄存器的指令集架构一般都是以一地址指令、二地址指令、三地址指令为主
JVM
发展历程
Classic VM
:1996年由Sun公司随着JDK1.0
发布,世界上第一款商用JVM
,JDK1.4
时被淘汰,现在的openJDK
或者oracleJDK
使用的HotSpot VM
内置了Classic VM
该虚拟机的执行引擎没有提供JIT
即时编译器,解释器和JIT
即时编译器都能独立解释运行字节码文件,没有JIT
即时编译器只是JVM
需要逐行解释运行,向循环中的循环体也需要每次都解释执行,效率比较低下;JIT
即时编译器一旦确定有被反复执行的热点代码,就会将热点代码编译成本地机器指令缓存到方法区的CodeCache
中,后续调用相同的代码会直接使用缓存而不会再去逐行翻译
Classic VM
可以外挂JIT
即时编译器,但是一旦外挂使用了JIT
即时编译器,就无法使用解释器,即在Classic VM
中解释器和JIT
即时编译器无法协同工作,此时JIT
即时编译器会把所有代码都编译缓存起来,这种缓存比解释器现场编译字节码来的快,但是这样会导致JVM
启动时会编译大量代码,导致JVM
启动后很长一段时间都在执行编译工作造成JVM
长时间卡顿;因此JIT
即时编译器也不是优化做的越多越好,做的太多系统启动的时候就会等待很长时间,JIT
即时编译器经过多年顶尖的工程师优化和解释器配合使用致使今天Java
的运行性能已经不亚于C
和C++
Exact VM
:JDK1.2
由SUN公司发布
提供准确式内存管理,虚拟机可以知道指定内存中的数据类型,不知道数据类型会带来一些麻烦,比如标记整理算法让JVM
中的内存更紧凑会移动对象的位置,如果不知道内存存放的是数据本身还是引用会带来一些麻烦,在Classic VM
中没有这种内存管理功能,Classic VM
通过句柄额外记录对象的内存地址来查找对象,带来额外的开销
实现了解释器和即时编译器的混合工作,即时编译器还可以实时探测哪些代码是热点高频代码
Exact VM
只在Sun
公司自己的Solaris
平台短暂使用,其他平台还是使用的Classic VM
,还没投入其他平台就被HotSpot
替换
HotSpot VM
:HotSpot
最初不是SUN公司的产品,是一家名为Longview Technologies
的小公司设计,97年被SUN公司收购,JDK1.3
成为默认虚拟机一直到现在
HotSpot
虚拟机占有绝对的市场地位,不管是openJDK
还是oracleJDK
都用的是HotSpot
虚拟机,这里主要也只介绍HotSpot
虚拟机,不同的虚拟机实现使用的机制是不同的;openJDK
开源的同时也带着将HotSpot VM
开源了
方法区也是针对HotSpot
独有的,像J9
、JRockit
都没有方法区[永久代只是针对HotSpot
来说的,JDK8
时HotSpot
引入了元空间,元空间和JRockit
一样使用本地空间]
从服务端、桌面、移动端到嵌入式都有HotSpot
的身影
HotSpot
的名字就指的是它的热点代码探测技术,通过程序计数器找到最具编译价值的代码,编译成机器指令缓存起来,需要时直接拷贝到栈上,同时即时编译器和解释器还可以协同工作,在最优程序响应时间和最佳执行性能之间自动平衡
JRockit VM
:由BEA
公司发布,后被Oracle
收购
JRockit VM
专注于服务器端应用,服务端应用最大的特点就是不太关注程序启动速度,比较关注程序的响应时间,因此JRockit VM
内部没有解释器,全部代码都靠即时编译器编译;大量行业基准测试显示JRockit
虚拟机是世界上最快的JVM
,没有之一;使用JRockit VM
一些性能甚至能提升超过70%,硬件成本减少高达50%
JRockit
的JRockit Real Time
面向延迟敏感型应用的解决方案提供毫秒、微秒级的JVM响应时间,适合财务、军事指挥、电信网络等场景
JRockit
的Mission Control
组件可以以极低的开销监控、管理和分析生产环境的应用程序,这个套件比较有用,被Oracle
于JDK8
整合到HotSpot
中形成了现在的JDK Mission Control
即JMC
,具体分成了三个独立的应用程序,包含内存泄漏的检测器、JVM
的运行时分析器和管理控制台,JMC
的主要功能就是监控应用程序的内存泄漏;但是HotSpot
和JRockit
的架构差别比较大,整合的比较有限,整合过程中JRockit
团队占主导,Java
之父高斯林从oracle
离职跳槽Google
,研究人工智能和水下机器人去了
J9 VM
[IBM Technology for Java Virtual Machine
]:由IBM发布,简称IT4J
,内部代号J9
市场定位和HotSpot
接近,作为服务端、桌面、嵌入式等多用途VM
,广泛用于IBM
的各种Java
产品
在IBM
自家产品上测试速度世界最快,但是通用性和其他产品上的性能比不上JRockit
,而且在windows
场景下使用Bug
很多
在可预见的将来J9
不会出售,因为一方面J9
比较适合IBM
自家的产品,在其他平台上表现一般;此外如果J9
被出售,一旦迭代J9
就可能不再针对其自己产品进行优化,上层产品的质量就得不到保证;除非IBM
被整体打包出售
2017年,IBM
发布开源J9 VM
,命名为OpenJ9
,交给Eclipse
基金会管理
CDC/CLDC HotSpot Implementation VM
:oracle
在Java ME
方向发布的两款虚拟机
诺基亚时代,手机排行榜七八个都是诺基亚的,当时用的塞班系统,游戏和应用程序都是用Java
开发的,使用的就是Java ME
产品线,现在手机被Android
和IOS
二分天下,Java ME
几乎已经失去移动端市场
KVM
是CLDC-HI
的早期产品,KVM
因为简单、轻量和高度可移植型在更低端设备比如智能控制器、传感器和老年机上还使用的是KVM
Azul VM
和BEA Liquid
:这两款虚拟机比前面三大通用平台高性能JVM
性能还要高,原因就是这两款虚拟机都与特定的硬件平台绑定,软硬件有极高的耦合度
Azul VM
是Azul Systems
公司基于HotSpot
改进而来,运行该公司专有硬件Vega
系统;每个Azul VM
实例都可以管理数十个CPU和数百GB内存的硬件资源,提供在巨大内存空间内GC时间可控的垃圾回收器;并且实现专有硬件优化的线程调度功能
2010年,Azul Systems
公司转向软件,发布了Zing JVM
,在通用x86平台上提供接近于Vega系统的特性;号称在低延迟和快速预热场景方面表现比HotSpot
还要好
BEA Liquid
是BEA
公司发布的,运行在BEA
的Hypervisor
系统上,Liquid VM
可以理解成JRockit
的一个虚拟化版本,不需要依赖操作系统就可以直接操控硬件实现一个专用操作系统的线程调度[无需切换内核态和用户态,能极致发挥硬件的性能]、文件系统、网络支持等功能;BEA Liquid
随着JRockit
的终止开发也停止开发了,归根结底应用场景有限
Apache Harmony
:Apache
发布,由IBM
和Intel
联合开发的开源JVM
,开发主体是Intel
JCP
组织对Java
语言的更新具有绝对的话语权,SUN
公司在JCP
中的话语权很重,因为IBM
老攻击SUN
,导致SUN
坚决不同意Harmony
获得JCP
认证,逼得IBM
[干活的是Intel
]最终放弃了Apache Harmony
的更新,IBM
转而参与OpenJDK
,
Apache Harmony
没有在Java
领域大规模商用,但是在安卓的SDK
中被大量使用,90%的代码使用Java
语法,字节码结构和链接模型都不符合JCP
规范,这是一款比较特别的虚拟机
Microsoft JVM
:Java
语言诞生之初是因为在浏览器中运行Java Applets
小程序火起来的,微软为了在IE3浏览器中支持Java Applets
,开发了Microsoft JVM
当时这款虚拟机是Windows
平台下运行性能最好的,而且只能在windows
平台下运行;1997年SUN以侵犯商标、不正当竞争指控微软,微软赔了很多钱;此后微软在WindowsXP SP3
中移除了Microsoft JVM
,因此现在Windows
平台上需要安装HotSpot
虚拟机
TaobaoJVM
:由AliJVM
团队基于HotSpot
深度定制开源的服务器版JVM
,简称AJDK
,国内Java
使用最全面彻底的公司就是阿里[技术层出不穷,要学习什么技术只需要关注国内大厂、美国大厂使用什么技术,绝对不会出问题,比如阿里在大数据方向已经全面倒向Flink
]
创新性的GCIH
[GC invisible heap]技术实现off-heap
,将生命周期较长的Java
对象从堆空间移到堆外,避免这些对象的垃圾回收管理,降低了GC的频率提高垃圾回收的效率;并且实现了GCIH
中的对象在多个JVM
进程中共享[正常来说进程间的数据一般是不共享的]
使用crc32
指令降低对JNI
的调用开销
提供针对大数据场景的ZenGC
Taobao JVM
在阿里产品上性能高,但是在硬件产品上严重依赖Intel
的CPU
[凡是和硬件操作系统耦合深的JVM
性能一般都比较好],损失兼容性,淘宝、天猫的产品全都把Oracle
的JVM
替换成了Taobao JVM
[一般做的比较大都会基于自己的产品体系定制JVM
保证产品的性能]
Dalvik VM
:这款虚拟机只能称作虚拟机,不能称为Java
虚拟机,没有遵循Java
虚拟机规范,由谷歌发布应用于Android
系统的虚拟机,在Android2.2
提供了JIT
即时编译器
Android5.0
以前使用的Dalvik VM
,此后替换为支持提前编译技术[AOT]的ART VM
提前编译指可以直接把源文件不经过字节码直接编译成机器指令,执行效率更高
因为没有遵循Java
虚拟机规范,不能直接执行Java
的字节码文件,执行dex
[Dalvik Executable]格式的文件,dex
格式文件可以通过Class
文件转化得到,使用Java
语法编写应用程序,可以直接使用大部分Java API
安卓应用程序都是以.apk
结尾的文件,直接把后缀改成zip
就可以解压,APP
中有很多小图标,因此有很多细碎的小文件,解压会比较慢,解压就能看到APP
的实际目录结构;里面就有很多.dex
为后缀的文件,就相当于Java
中的.class
文件,dex
文件是由字节码文件变形转化而来的,和Java
有一定渊源;主要是GooGle考虑到C
语言的执行效率高,但是开发效率低,同时也是SUN
公司希望GooGle
使用Java
作为安卓的开发语言,也是这铸就了Java
的地位,但是使用Java
作为开发语言确不能在Java
虚拟机上运行,后来还被oracle
告了赔了88亿美元
采用基于寄存器的指令集架构,执行效率高,和硬件耦合度高
Graal VM
:2018年oracle
发布,号称Run Programs Faster Anywhere
,在HotSpot
基础上增强而成的跨语言全栈虚拟机,可以作为Java
、Scala
、Groovy
、Kotlin
、C
、C++
、JavaScript
、Ruby
、Python
、R
等任何语言的运行平台使用
支持不同语言中混用对方接口和对象,支持使用这些语言编写的本地库文件
原理是将语言的源代码编程成虚拟机能识别的类似字节码的中间语言格式,做到虚拟机与具体的机器特性相关而不是与具体的语言相关,只有Graal VM
取代HotSpot
的希望是最大的,没有意外oracle
会将Graal VM
作为重点发展的项目
除了以上虚拟机还有非常多针对具体应用场景的虚拟机
Java
发展史上的重要事件
1990年,SUN
公司的Green Team
小组开发出新的程序语言命名为Oak
,后改名为Java
1995年,SUN
公司正式发布Java
和HotJava
产品,被认为是Java
语言的元年
1996年,发布JDK1.0
1998年,发布JDK1.2
,增加了JSP/Servlet
、EJB
规范,将Java
分成J2EE
、J2SE
、J2ME
,标志着Java
向企业应用、桌面应用、移动设备应用三大领域挺进;最初Java
被设计出来主要是瞄准嵌入式市场,但是现在随着发展,HotSpot
虚拟机的宿主环境已经不再局限于嵌入式平台[嵌入式平台被认为是资源受限平台]
2000年,发布JDK1.3
,正式发布Java HotSpot Virtual Machine
,成为Java
的默认虚拟机
2002年,发布JDK1.4
,Java
最初的虚拟机Classic
虚拟机退出历史舞台、同年微软的.Net
平台发布,技术实现和目标用户上都和Java
很相似,现在二者也在竞争
2003年,以JVM
作为跨语言平台的一些新语言Scala
发布,同时一些其他语言Groovy
也加入Java
阵营
2004年,发布JDK1.5
,同时改名为JavaSE 5.0
,JDK1.5
是里程碑的版本,很多新特性都是JDK1.5
加入进来的[泛型、注解、反射、动态代理、并发编程],现在生产中最老的版本也就JDK1.5
,不会再有之前的
2006年,发布JDK6
,同年Java
基于APL协议开源建立openJDK
[此时有两个产品,一个是SUN
公司的SUNJDK
、一个是openJDK
,最初二者除了版权注释外二者基本没有什么区别],HotSpot
虚拟机也成为OpenJDK
中的默认虚拟机
2007年,Clojure
语言使用JVM
作为解释运行的平台
2008年,Oracle
收购BEA
,从BEA
得到Jrockit
虚拟机[市面上有三大主流虚拟机HotSpot
、JRockit
、J9
],注意此时Oracle
和SUN
公司没啥关系
2009年,Twitter
将后台大部分程序从Ruby
迁移到Scala
2010年,Oracle
花了74亿美元收购SUN
公司[因为美国发生金融危机],Oracle
获得Java
商标和HotSpot
虚拟机,Oracle
后续将JRockit
和HotSpot
做了整合HotRockit
,随着JDK 8
在2014年发布,二者整合难度很大,因为二者的架构存在明显的差异,现在的JDK 8
的虚拟机仍然叫做HotSpot
,实际上是二者整合后的版本
Oracle
主要获取到Java
的商标,Java
语言本身的管理归到JCP
组织,在JCP
组织中Oracle
的话语权比较重
2011年,JDK 7
发布,在JDK 1.7u4
中正式启用新的垃圾回收器G1
2017年,JDK 9
发布,将G1
设置为默认垃圾回收器替代并发垃圾回收器CMS
,同年IBM
的J9
开源,形成现在的Open J9
社区
2018年,Android
的Java
侵权案判决,Google
赔偿Oracle
88亿美元,好家伙白嫖,传言Oracle
公司的法务人员比开发人员还多果然名不虚传
同年,Oracle
将JDBC
、JMS
、Servlet
捐赠给Eclipse
基金会打理,JavaEE
成为历史名称[因为Oracle要求不能使用Java
的商标,要求商标完全归Oracle
所有]
同年,JDK11
发布[该版本是LTS
版本],同时发布革命性的垃圾回收器ZGC
[G1
垃圾回收器目前还是比较主流,ZGC
目前还是实验性,未来肯定会将G1
换成ZGC
,很多实验性数据证明性能已经远超G1
]
同年调整JDK
的授权许可,明确JDK
每次发布都会发布OpenJDK
和OracleJDK
两个版本,OpenJDK
基于OPL
协议,OracleJDK
基于OPN
协议;OpenJDK
的维护区间只有半年,如果存在Bug
且距离发布时间超过半年只能通过安装更新的版本来解决,OracleJDK
的维护区间为三年[OracleJDK
商业使用需要付费,个人使用不需要付费],JDK11
以前OracleJDK
还会存在一些OpenJDK
中没有的闭源功能,JDK11
中可以认为OpenJDK
和OracleJDK
实质上是完全一样的
2019年,JDK12
发布,OpenJDK
加入了RedHat
开发的Shenandoah GC
,二者都处于实现阶段,ZGC
的性能要表现得好一些,因为ZGC
是Oracle
自家产品,Oracle
没有将Shenandoah GC
加入到OracleJDK
中,此时竟然出现了商用版本的功能竟然比开源版本的功能少的现象
类加载子系统
概念:
类加器子系统负责从文件系统或者网络中将一个或多个字节码文件以二进制流的方式加载到内存结构中初始化成一个或多个Class
实例[元数据模板,通过元数据模板的构造器就能在堆空间中创建单个或多个对应类对象,通过Class
对象的getClassLoader()
方法可以获取负责该过程的类加载器对象,通过对象的getClass()
方法能获取到对应的Class
对象],除了该Class
实例外,方法区中还会存放运行时常量池信息[需要用的常量池加载到内存中就称为运行时常量池],字符串字面值和数字常量
字节码文件在文件头有一个特定的模数标识cafebaby
,该模数会参与链接阶段的验证
类加载器只负责字节码文件的加载,字节码文件是否可以被执行是由执行引擎决定的
类加载包含加载、链接和初始化三个环节
类加载过程
当前类HelloLoader
是否装载,已装载直接进入链接流程
没有装载使用类加载器进行装载[自定义类使用应用类加载器装载],如果字节码文件不是一个合法的字节码文件,类加载器加载的过程中会抛出异常,加载成功再内存中生成元数据模板即对应Class
实例
有了Class
对象执行链接步骤
初始化对象实例
加载环节
概念:
通过一个类的全限定类名从物理磁盘或者网络获取该类的二进制字节流
将字节流代表的静态存储结构转化为方法区的运行时数据结构
在内存中生成一个代表该类的java.lang.Class
对象,该对象作为方法区中该类的各种数据访问入口
被加载的字节码文件来源
本地文件系统
从网络中获取,典型应用就是Web Applet
从zip
压缩包中读取,这也是jar
、war
压缩格式的读取基础[jar
包war
包解压后都是字节码文件]
运行时通过计算生成,典型应用就是动态代理技术java.lang.reflect.Proxy
由其他文件生成,典型应用就是JSP
应用
从专有数据库中提取[比较少见]
从加密文件中解密获取,是一种防止字节码文件被反编译的保护措施[比如将.apk
格式替换成.zip
格式解压就能获取字节码文件,对字节码文件进行反编译就能盗版一个软件或者寻找软件漏洞,因此一般都会对字节码文件进行加密防止我们这种人反编译字节码,真正运行的时候会自动对加密后的字节码文件进行一个解密操作]